@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
|
@@ -1,24 +1,14 @@
|
|
|
1
1
|
import "node:module";
|
|
2
2
|
import { jsx } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
import { buildLocalizedUrl } from "./utils.mjs";
|
|
3
|
+
import { Link } from "./Link.mjs";
|
|
4
|
+
let warnedDeprecation = false;
|
|
6
5
|
const I18nLink = ({ to, children, ...props })=>{
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const localizedTo = buildLocalizedUrl(to, currentLang, supportedLanguages, localisedUrls);
|
|
11
|
-
if ('development' === process.env.NODE_ENV && hasRouter && !params.lang) console.warn("I18nLink is being used outside of a :lang dynamic route context. This may cause unexpected behavior. Please ensure I18nLink is used within a route that has a :lang parameter.");
|
|
12
|
-
if (!hasRouter || !Link) {
|
|
13
|
-
const { prefetch: _prefetch, preload: _preload, ...anchorProps } = props;
|
|
14
|
-
return /*#__PURE__*/ jsx("a", {
|
|
15
|
-
href: localizedTo,
|
|
16
|
-
...anchorProps,
|
|
17
|
-
children: children
|
|
18
|
-
});
|
|
6
|
+
if ('development' === process.env.NODE_ENV && !warnedDeprecation) {
|
|
7
|
+
warnedDeprecation = true;
|
|
8
|
+
console.warn("[plugin-i18n] I18nLink is deprecated. Import { Link } from '@modern-js/plugin-i18n/runtime' instead — it accepts the same language-agnostic `to` values.");
|
|
19
9
|
}
|
|
20
10
|
return /*#__PURE__*/ jsx(Link, {
|
|
21
|
-
to:
|
|
11
|
+
to: to,
|
|
22
12
|
...props,
|
|
23
13
|
children: children
|
|
24
14
|
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import "node:module";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { useModernI18n } from "./context.mjs";
|
|
5
|
+
import { canonicalPath } from "./localizedPaths.mjs";
|
|
6
|
+
import { useI18nRouterAdapter } from "./routerAdapter.mjs";
|
|
7
|
+
import { buildLocalizedUrl, splitUrlTarget } from "./utils.mjs";
|
|
8
|
+
const EXTERNAL_TARGET_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
9
|
+
const warnedTargets = new Set();
|
|
10
|
+
const warnOnce = (key, message)=>{
|
|
11
|
+
if ('development' !== process.env.NODE_ENV || warnedTargets.has(key)) return;
|
|
12
|
+
warnedTargets.add(key);
|
|
13
|
+
console.warn(message);
|
|
14
|
+
};
|
|
15
|
+
const interpolateRouteParams = (pathname, params)=>{
|
|
16
|
+
if (!/[$:*{]/.test(pathname)) return pathname;
|
|
17
|
+
const resolveParam = (name)=>{
|
|
18
|
+
const value = params?.[name];
|
|
19
|
+
return void 0 === value ? void 0 : String(value);
|
|
20
|
+
};
|
|
21
|
+
const segments = pathname.split('/').map((segment)=>{
|
|
22
|
+
if (!segment) return segment;
|
|
23
|
+
if (segment.startsWith('{-$') && segment.endsWith('}')) {
|
|
24
|
+
const value = resolveParam(segment.slice(3, -1));
|
|
25
|
+
return void 0 === value ? null : encodeURIComponent(value);
|
|
26
|
+
}
|
|
27
|
+
if ('$' === segment || '*' === segment) {
|
|
28
|
+
const value = resolveParam('_splat') ?? resolveParam('*');
|
|
29
|
+
return void 0 === value ? null : value.split('/').map(encodeURIComponent).join('/');
|
|
30
|
+
}
|
|
31
|
+
if (segment.startsWith('$')) {
|
|
32
|
+
const value = resolveParam(segment.slice(1));
|
|
33
|
+
if (void 0 === value) {
|
|
34
|
+
warnOnce(`missing-param:${pathname}:${segment}`, `[plugin-i18n] <Link to="${pathname}"> is missing required param "${segment.slice(1)}".`);
|
|
35
|
+
return segment;
|
|
36
|
+
}
|
|
37
|
+
return encodeURIComponent(value);
|
|
38
|
+
}
|
|
39
|
+
if (segment.startsWith(':')) {
|
|
40
|
+
const optional = segment.endsWith('?');
|
|
41
|
+
const name = segment.slice(1, optional ? -1 : void 0);
|
|
42
|
+
const value = resolveParam(name);
|
|
43
|
+
if (void 0 === value) {
|
|
44
|
+
if (optional) return null;
|
|
45
|
+
warnOnce(`missing-param:${pathname}:${segment}`, `[plugin-i18n] <Link to="${pathname}"> is missing required param "${name}".`);
|
|
46
|
+
return segment;
|
|
47
|
+
}
|
|
48
|
+
return encodeURIComponent(value);
|
|
49
|
+
}
|
|
50
|
+
return segment;
|
|
51
|
+
}).filter((segment)=>null !== segment);
|
|
52
|
+
return segments.join('/') || '/';
|
|
53
|
+
};
|
|
54
|
+
const normalizeSearch = (search, searchFromTo)=>{
|
|
55
|
+
if (search && 'object' == typeof search) {
|
|
56
|
+
const entries = Object.entries(search).filter(([, value])=>null != value);
|
|
57
|
+
const searchObject = Object.fromEntries(entries.map(([key, value])=>[
|
|
58
|
+
key,
|
|
59
|
+
String(value)
|
|
60
|
+
]));
|
|
61
|
+
const params = new URLSearchParams(searchObject);
|
|
62
|
+
const serialized = params.toString();
|
|
63
|
+
return {
|
|
64
|
+
searchString: serialized ? `?${serialized}` : '',
|
|
65
|
+
searchObject
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const raw = 'string' == typeof search && search ? search : searchFromTo;
|
|
69
|
+
if (!raw) return {
|
|
70
|
+
searchString: '',
|
|
71
|
+
searchObject: void 0
|
|
72
|
+
};
|
|
73
|
+
const searchString = raw.startsWith('?') ? raw : `?${raw}`;
|
|
74
|
+
const searchObject = {};
|
|
75
|
+
new URLSearchParams(searchString).forEach((value, key)=>{
|
|
76
|
+
searchObject[key] = value;
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
searchString,
|
|
80
|
+
searchObject
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
const splitActiveProps = (active, activeProps)=>{
|
|
84
|
+
if (!active || !activeProps) return {};
|
|
85
|
+
return activeProps;
|
|
86
|
+
};
|
|
87
|
+
const mergeClassNames = (...values)=>{
|
|
88
|
+
const classNames = values.filter((value)=>'string' == typeof value && value.length > 0);
|
|
89
|
+
return classNames.length > 0 ? classNames.join(' ') : void 0;
|
|
90
|
+
};
|
|
91
|
+
const Link = (props)=>{
|
|
92
|
+
const { to, params, children, hash: hashProp, search: searchProp, hashScrollIntoView, activeOptions, activeProps, ...rest } = props;
|
|
93
|
+
const adapter = useI18nRouterAdapter();
|
|
94
|
+
const { language, supportedLanguages, localisedUrls } = useModernI18n();
|
|
95
|
+
const config = {
|
|
96
|
+
languages: supportedLanguages,
|
|
97
|
+
localisedUrls
|
|
98
|
+
};
|
|
99
|
+
const isExternal = EXTERNAL_TARGET_RE.test(to);
|
|
100
|
+
const isBareHash = to.startsWith('#');
|
|
101
|
+
const target = useMemo(()=>{
|
|
102
|
+
if (isExternal || isBareHash) return null;
|
|
103
|
+
const { pathname, search: searchFromTo, hash: hashFromTo } = splitUrlTarget(to);
|
|
104
|
+
const interpolated = interpolateRouteParams(pathname || '/', params);
|
|
105
|
+
const firstSegment = interpolated.split('/').filter(Boolean)[0];
|
|
106
|
+
if (firstSegment && supportedLanguages.includes(firstSegment)) warnOnce(`lang-prefix:${to}`, `[plugin-i18n] <Link to="${to}"> starts with a language prefix. Write language-agnostic canonical paths; the Link localizes them automatically.`);
|
|
107
|
+
const localizedPathname = buildLocalizedUrl(interpolated, language, supportedLanguages, localisedUrls);
|
|
108
|
+
const hash = hashProp ?? (hashFromTo ? hashFromTo.slice(1) : '');
|
|
109
|
+
const { searchString, searchObject } = normalizeSearch(searchProp, searchFromTo);
|
|
110
|
+
return {
|
|
111
|
+
canonicalPathname: interpolated,
|
|
112
|
+
localizedPathname,
|
|
113
|
+
hash,
|
|
114
|
+
searchString,
|
|
115
|
+
searchObject,
|
|
116
|
+
href: `${localizedPathname}${searchString}${hash ? `#${hash}` : ''}`
|
|
117
|
+
};
|
|
118
|
+
}, [
|
|
119
|
+
to,
|
|
120
|
+
params,
|
|
121
|
+
hashProp,
|
|
122
|
+
searchProp,
|
|
123
|
+
isExternal,
|
|
124
|
+
isBareHash,
|
|
125
|
+
language,
|
|
126
|
+
supportedLanguages,
|
|
127
|
+
localisedUrls
|
|
128
|
+
]);
|
|
129
|
+
const isActive = useMemo(()=>{
|
|
130
|
+
if (!target || !adapter.location) return false;
|
|
131
|
+
const current = canonicalPath(adapter.location.pathname, config);
|
|
132
|
+
const targetCanonical = canonicalPath(target.canonicalPathname, config);
|
|
133
|
+
const exact = activeOptions?.exact ?? '/' === targetCanonical;
|
|
134
|
+
if (current === targetCanonical) return true;
|
|
135
|
+
if (exact) return false;
|
|
136
|
+
return current.startsWith('/' === targetCanonical ? '/' : `${targetCanonical}/`);
|
|
137
|
+
}, [
|
|
138
|
+
target,
|
|
139
|
+
adapter.location,
|
|
140
|
+
activeOptions?.exact,
|
|
141
|
+
supportedLanguages,
|
|
142
|
+
localisedUrls
|
|
143
|
+
]);
|
|
144
|
+
const resolvedActiveProps = splitActiveProps(isActive, activeProps);
|
|
145
|
+
const activeAttributes = isActive ? {
|
|
146
|
+
'data-status': 'active',
|
|
147
|
+
'aria-current': rest['aria-current'] ?? resolvedActiveProps['aria-current'] ?? 'page'
|
|
148
|
+
} : {};
|
|
149
|
+
if (!target) {
|
|
150
|
+
const { prefetch: _prefetch, preload: _preload, replace: _replace, ...anchorProps } = rest;
|
|
151
|
+
return /*#__PURE__*/ jsx("a", {
|
|
152
|
+
href: to,
|
|
153
|
+
...anchorProps,
|
|
154
|
+
children: children
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const { Link: RouterLink, hasRouter, framework } = adapter;
|
|
158
|
+
if (!hasRouter || !RouterLink) {
|
|
159
|
+
const { prefetch: _prefetch, preload: _preload, replace: _replace, ...anchorProps } = rest;
|
|
160
|
+
const { className: activeClassName, style: activeStyle, ...activeRest } = resolvedActiveProps;
|
|
161
|
+
return /*#__PURE__*/ jsx("a", {
|
|
162
|
+
href: target.href,
|
|
163
|
+
...anchorProps,
|
|
164
|
+
...activeRest,
|
|
165
|
+
...activeAttributes,
|
|
166
|
+
className: mergeClassNames(rest.className, activeClassName),
|
|
167
|
+
style: {
|
|
168
|
+
...rest.style,
|
|
169
|
+
...activeStyle
|
|
170
|
+
},
|
|
171
|
+
children: children
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const { className: activeClassName, style: activeStyle, ...activeRest } = resolvedActiveProps;
|
|
175
|
+
const mergedClassName = mergeClassNames(rest.className, activeClassName);
|
|
176
|
+
const mergedStyle = {
|
|
177
|
+
...rest.style,
|
|
178
|
+
...activeStyle
|
|
179
|
+
};
|
|
180
|
+
if ('tanstack' === framework) return /*#__PURE__*/ jsx(RouterLink, {
|
|
181
|
+
to: target.localizedPathname,
|
|
182
|
+
...target.searchObject ? {
|
|
183
|
+
search: target.searchObject
|
|
184
|
+
} : {},
|
|
185
|
+
...target.hash ? {
|
|
186
|
+
hash: target.hash
|
|
187
|
+
} : {},
|
|
188
|
+
...void 0 === hashScrollIntoView ? {} : {
|
|
189
|
+
hashScrollIntoView
|
|
190
|
+
},
|
|
191
|
+
...rest,
|
|
192
|
+
...activeRest,
|
|
193
|
+
...activeAttributes,
|
|
194
|
+
className: mergedClassName,
|
|
195
|
+
style: mergedStyle,
|
|
196
|
+
children: children
|
|
197
|
+
});
|
|
198
|
+
return /*#__PURE__*/ jsx(RouterLink, {
|
|
199
|
+
to: target.href,
|
|
200
|
+
...rest,
|
|
201
|
+
...activeRest,
|
|
202
|
+
...activeAttributes,
|
|
203
|
+
className: mergedClassName,
|
|
204
|
+
style: mergedStyle,
|
|
205
|
+
children: children
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
const runtime_Link = Link;
|
|
209
|
+
export default runtime_Link;
|
|
210
|
+
export { Link, interpolateRouteParams };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "node:module";
|
|
@@ -13,7 +13,7 @@ import { detectLanguageWithPriority, exportServerLngToWindow, mergeDetectionOpti
|
|
|
13
13
|
import { useI18nextLanguageDetector } from "./i18n/detection/middleware.mjs";
|
|
14
14
|
import { getI18nextInstanceForProvider } from "./i18n/instance.mjs";
|
|
15
15
|
import { changeI18nLanguage, ensureLanguageMatch, initializeI18nInstance, setupClonedInstance } from "./i18n/utils.mjs";
|
|
16
|
-
import { getPathname } from "./utils.mjs";
|
|
16
|
+
import { buildLocalizedUrl, getPathname, splitUrlTarget } from "./utils.mjs";
|
|
17
17
|
import "./types.mjs";
|
|
18
18
|
const i18nPlugin = (options)=>({
|
|
19
19
|
name: '@modern-js/plugin-i18n',
|
|
@@ -137,5 +137,7 @@ const i18nPlugin = (options)=>({
|
|
|
137
137
|
});
|
|
138
138
|
const runtime = i18nPlugin;
|
|
139
139
|
export { I18nLink } from "./I18nLink.mjs";
|
|
140
|
+
export { Link } from "./Link.mjs";
|
|
141
|
+
export { canonicalPath, localizePath, useLocalizedLocation, useLocalizedPaths } from "./localizedPaths.mjs";
|
|
140
142
|
export default runtime;
|
|
141
|
-
export { i18nPlugin, useModernI18n };
|
|
143
|
+
export { buildLocalizedUrl, i18nPlugin, splitUrlTarget, useModernI18n };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import "node:module";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { resolveCanonicalLocalisedPath, resolveLocalisedUrlsConfig } from "../shared/localisedUrls.mjs";
|
|
4
|
+
import { useModernI18n } from "./context.mjs";
|
|
5
|
+
import { useI18nRouterAdapter } from "./routerAdapter.mjs";
|
|
6
|
+
import { buildLocalizedUrl, splitUrlTarget } from "./utils.mjs";
|
|
7
|
+
const localizePath = (pathname, language, config)=>buildLocalizedUrl(pathname, language, config.languages, config.localisedUrls);
|
|
8
|
+
const canonicalPath = (target, config)=>{
|
|
9
|
+
const { pathname, search, hash } = splitUrlTarget(target);
|
|
10
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
11
|
+
const pathWithoutLanguage = segments.length > 0 && config.languages.includes(segments[0]) ? `/${segments.slice(1).join('/')}` : pathname || '/';
|
|
12
|
+
const localisedUrlsConfig = resolveLocalisedUrlsConfig(config.localisedUrls);
|
|
13
|
+
const resolvedPath = localisedUrlsConfig.enabled ? resolveCanonicalLocalisedPath(pathWithoutLanguage, config.languages, localisedUrlsConfig.map) : pathWithoutLanguage;
|
|
14
|
+
return `${resolvedPath}${search}${hash}`;
|
|
15
|
+
};
|
|
16
|
+
const useLocalizedPaths = ()=>{
|
|
17
|
+
const { supportedLanguages, localisedUrls } = useModernI18n();
|
|
18
|
+
return useMemo(()=>{
|
|
19
|
+
const config = {
|
|
20
|
+
languages: supportedLanguages,
|
|
21
|
+
localisedUrls
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
localizePath: (pathname, language)=>localizePath(pathname, language, config),
|
|
25
|
+
canonicalPath: (pathname)=>canonicalPath(pathname, config)
|
|
26
|
+
};
|
|
27
|
+
}, [
|
|
28
|
+
supportedLanguages,
|
|
29
|
+
localisedUrls
|
|
30
|
+
]);
|
|
31
|
+
};
|
|
32
|
+
const useLocalizedLocation = ()=>{
|
|
33
|
+
const { language, supportedLanguages, localisedUrls } = useModernI18n();
|
|
34
|
+
const { location } = useI18nRouterAdapter();
|
|
35
|
+
const pathname = location?.pathname ?? '/';
|
|
36
|
+
const search = location?.search ?? '';
|
|
37
|
+
const hash = location?.hash ?? '';
|
|
38
|
+
return useMemo(()=>{
|
|
39
|
+
const config = {
|
|
40
|
+
languages: supportedLanguages,
|
|
41
|
+
localisedUrls
|
|
42
|
+
};
|
|
43
|
+
const alternates = {};
|
|
44
|
+
for (const supportedLanguage of supportedLanguages)alternates[supportedLanguage] = `${localizePath(pathname, supportedLanguage, config)}${search}${hash}`;
|
|
45
|
+
return {
|
|
46
|
+
language,
|
|
47
|
+
canonical: canonicalPath(pathname, config),
|
|
48
|
+
alternates
|
|
49
|
+
};
|
|
50
|
+
}, [
|
|
51
|
+
language,
|
|
52
|
+
supportedLanguages,
|
|
53
|
+
localisedUrls,
|
|
54
|
+
pathname,
|
|
55
|
+
search,
|
|
56
|
+
hash
|
|
57
|
+
]);
|
|
58
|
+
};
|
|
59
|
+
export { canonicalPath, localizePath, useLocalizedLocation, useLocalizedPaths };
|
|
@@ -17,16 +17,30 @@ const getLanguageFromPath = (pathname, languages, fallbackLanguage)=>{
|
|
|
17
17
|
if (languages.includes(firstSegment)) return firstSegment;
|
|
18
18
|
return fallbackLanguage;
|
|
19
19
|
};
|
|
20
|
-
const
|
|
20
|
+
const splitUrlTarget = (target)=>{
|
|
21
|
+
const hashIndex = target.indexOf('#');
|
|
22
|
+
const hash = hashIndex >= 0 ? target.slice(hashIndex) : '';
|
|
23
|
+
const beforeHash = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
|
|
24
|
+
const searchIndex = beforeHash.indexOf('?');
|
|
25
|
+
const search = searchIndex >= 0 ? beforeHash.slice(searchIndex) : '';
|
|
26
|
+
const pathname = searchIndex >= 0 ? beforeHash.slice(0, searchIndex) : beforeHash;
|
|
27
|
+
return {
|
|
28
|
+
pathname,
|
|
29
|
+
search,
|
|
30
|
+
hash
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
const buildLocalizedUrl = (target, language, languages, localisedUrls)=>{
|
|
34
|
+
const { pathname, search, hash } = splitUrlTarget(target);
|
|
21
35
|
const segments = pathname.split('/').filter(Boolean);
|
|
22
36
|
const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
|
|
23
|
-
const pathWithoutLanguage = segments.length > 0 && languages.includes(segments[0]) ? `/${segments.slice(1).join('/')}` : pathname;
|
|
37
|
+
const pathWithoutLanguage = segments.length > 0 && languages.includes(segments[0]) ? `/${segments.slice(1).join('/')}` : pathname || '/';
|
|
24
38
|
const resolvedPath = localisedUrlsConfig.enabled ? resolveLocalisedPath(pathWithoutLanguage, language, languages, localisedUrlsConfig.map) : pathWithoutLanguage;
|
|
25
39
|
const resolvedSegments = resolvedPath.split('/').filter(Boolean);
|
|
26
40
|
return `/${[
|
|
27
41
|
language,
|
|
28
42
|
...resolvedSegments
|
|
29
|
-
].join('/')}`;
|
|
43
|
+
].join('/')}${search}${hash}`;
|
|
30
44
|
};
|
|
31
45
|
const detectLanguageFromPath = (pathname, languages, localePathRedirect)=>{
|
|
32
46
|
if (!localePathRedirect) return {
|
|
@@ -54,4 +68,4 @@ const shouldIgnoreRedirect = (pathname, languages, ignoreRedirectRoutes)=>{
|
|
|
54
68
|
if ('function' == typeof ignoreRedirectRoutes) return ignoreRedirectRoutes(normalizedPath);
|
|
55
69
|
return ignoreRedirectRoutes.some((pattern)=>normalizedPath === pattern || normalizedPath.startsWith(`${pattern}/`));
|
|
56
70
|
};
|
|
57
|
-
export { buildLocalizedUrl, detectLanguageFromPath, getEntryPath, getLanguageFromPath, getPathname, shouldIgnoreRedirect };
|
|
71
|
+
export { buildLocalizedUrl, detectLanguageFromPath, getEntryPath, getLanguageFromPath, getPathname, shouldIgnoreRedirect, splitUrlTarget };
|
|
@@ -101,7 +101,7 @@ const transformLocalisedRoute = (route, parentCanonicalPath, parentLocalisedPath
|
|
|
101
101
|
if (!localisedUrlEntry) return [
|
|
102
102
|
baseRoute
|
|
103
103
|
];
|
|
104
|
-
return getLocalisedRoutePaths(canonicalPath, parentLocalisedPaths, languages, localisedUrlEntry).map((localisedPath, index)=>cloneRouteWithLocalisedPath(baseRoute, localisedPath, index));
|
|
104
|
+
return getLocalisedRoutePaths(canonicalPath, parentLocalisedPaths, languages, localisedUrlEntry).map((localisedPath, index)=>cloneRouteWithLocalisedPath(baseRoute, localisedPath, index, canonicalPath));
|
|
105
105
|
};
|
|
106
106
|
const legalRouteIdPart = (value)=>value.replace(/[^a-zA-Z0-9_$-]+/g, '_').replace(/^_+|_+$/g, '') || 'index';
|
|
107
107
|
const suffixRouteIds = (route, suffix)=>{
|
|
@@ -116,13 +116,14 @@ const suffixRouteIds = (route, suffix)=>{
|
|
|
116
116
|
} : {}
|
|
117
117
|
};
|
|
118
118
|
};
|
|
119
|
-
const cloneRouteWithLocalisedPath = (route, path, index)=>{
|
|
119
|
+
const cloneRouteWithLocalisedPath = (route, path, index, canonicalPath)=>{
|
|
120
120
|
const leadingLocaleParam = getLeadingLocaleParam(route.path);
|
|
121
121
|
const localisedPath = leadingLocaleParam ? normaliseRoutePath(`${leadingLocaleParam}/${path}`) : path;
|
|
122
122
|
const routeWithPath = {
|
|
123
123
|
...route,
|
|
124
124
|
path: localisedPath
|
|
125
125
|
};
|
|
126
|
+
routeWithPath.modernCanonicalPath = canonicalPath;
|
|
126
127
|
return 0 === index ? routeWithPath : suffixRouteIds(routeWithPath, legalRouteIdPart(localisedPath));
|
|
127
128
|
};
|
|
128
129
|
const applyLocalisedUrlsToRoutes = (routes, languages, localisedUrls)=>{
|
|
@@ -178,6 +179,12 @@ const buildPathFromPattern = (pattern, params)=>{
|
|
|
178
179
|
};
|
|
179
180
|
const resolveLocalisedPath = (pathname, targetLanguage, languages, localisedUrls)=>{
|
|
180
181
|
const normalizedPathname = normalisePathPattern(pathname);
|
|
182
|
+
for (const [canonicalPattern, localisedUrlEntry] of Object.entries(localisedUrls)){
|
|
183
|
+
const targetPattern = localisedUrlEntry[targetLanguage];
|
|
184
|
+
if (!targetPattern) continue;
|
|
185
|
+
const params = matchPathPattern(normalizedPathname, canonicalPattern);
|
|
186
|
+
if (params) return buildPathFromPattern(targetPattern, params);
|
|
187
|
+
}
|
|
181
188
|
for (const localisedUrlEntry of Object.values(localisedUrls)){
|
|
182
189
|
const targetPattern = localisedUrlEntry[targetLanguage];
|
|
183
190
|
if (targetPattern) for (const language of languages){
|
|
@@ -189,4 +196,18 @@ const resolveLocalisedPath = (pathname, targetLanguage, languages, localisedUrls
|
|
|
189
196
|
}
|
|
190
197
|
return normalizedPathname;
|
|
191
198
|
};
|
|
192
|
-
|
|
199
|
+
const resolveCanonicalLocalisedPath = (pathname, languages, localisedUrls)=>{
|
|
200
|
+
const normalizedPathname = normalisePathPattern(pathname);
|
|
201
|
+
for (const [canonicalPattern, localisedUrlEntry] of Object.entries(localisedUrls)){
|
|
202
|
+
const canonicalParams = matchPathPattern(normalizedPathname, canonicalPattern);
|
|
203
|
+
if (canonicalParams) return buildPathFromPattern(canonicalPattern, canonicalParams);
|
|
204
|
+
for (const language of languages){
|
|
205
|
+
const sourcePattern = localisedUrlEntry[language];
|
|
206
|
+
if (!sourcePattern) continue;
|
|
207
|
+
const params = matchPathPattern(normalizedPathname, sourcePattern);
|
|
208
|
+
if (params) return buildPathFromPattern(canonicalPattern, params);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return normalizedPathname;
|
|
212
|
+
};
|
|
213
|
+
export { applyLocalisedUrlsToRoutes, buildPathFromPattern, matchPathPattern, normalisePathPattern, resolveCanonicalLocalisedPath, resolveLocalisedPath, resolveLocalisedUrlsConfig, validateLocalisedUrls };
|
|
@@ -5,19 +5,10 @@ export interface I18nLinkProps {
|
|
|
5
5
|
[key: string]: any;
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* ```tsx
|
|
13
|
-
* // When current language is 'en' and to="/about"
|
|
14
|
-
* // The actual link will be "/en/about"
|
|
15
|
-
* <I18nLink to="/about">About</I18nLink>
|
|
16
|
-
*
|
|
17
|
-
* // When current language is 'zh' and to="/"
|
|
18
|
-
* // The actual link will be "/zh"
|
|
19
|
-
* <I18nLink to="/">Home</I18nLink>
|
|
20
|
-
* ```
|
|
8
|
+
* @deprecated Use {@link Link} from `@modern-js/plugin-i18n/runtime` instead.
|
|
9
|
+
* `Link` accepts the same language-agnostic `to` values and additionally
|
|
10
|
+
* supports `#hash`/`?query` targets, typed canonical routes, `params`
|
|
11
|
+
* interpolation and language-invariant active state.
|
|
21
12
|
*/
|
|
22
13
|
export declare const I18nLink: React.FC<I18nLinkProps>;
|
|
23
14
|
export default I18nLink;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import type { LinkParamsProp, LinkTargetPathname, ValidateLinkTo } from './canonicalRoutes';
|
|
3
|
+
export type LinkParams = Record<string, string | number | undefined>;
|
|
4
|
+
/**
|
|
5
|
+
* Interpolate `$param`, `:param`, optional (`{-$param}` / `:param?`) and splat
|
|
6
|
+
* (`$` / `*`) segments with concrete values before localization, so
|
|
7
|
+
* pattern-mapped slugs localize correctly.
|
|
8
|
+
*/
|
|
9
|
+
export declare const interpolateRouteParams: (pathname: string, params?: LinkParams) => string;
|
|
10
|
+
export interface LinkActiveOptions {
|
|
11
|
+
/**
|
|
12
|
+
* `true`: active only when the location matches the target exactly.
|
|
13
|
+
* `false`: also active when the location is nested under the target.
|
|
14
|
+
* Defaults to prefix matching, except for `/` which defaults to exact.
|
|
15
|
+
*/
|
|
16
|
+
exact?: boolean;
|
|
17
|
+
}
|
|
18
|
+
type AnchorRest = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href' | 'children'>;
|
|
19
|
+
export interface LinkBaseProps extends AnchorRest {
|
|
20
|
+
children?: React.ReactNode;
|
|
21
|
+
/** Hash fragment without the leading `#`. Overrides a `#hash` inside `to`. */
|
|
22
|
+
hash?: string;
|
|
23
|
+
/** Search params. Object form is passed natively to TanStack Link. */
|
|
24
|
+
search?: string | Record<string, unknown>;
|
|
25
|
+
hashScrollIntoView?: boolean | ScrollIntoViewOptions;
|
|
26
|
+
replace?: boolean;
|
|
27
|
+
prefetch?: 'intent' | 'render' | 'viewport' | 'none';
|
|
28
|
+
preload?: unknown;
|
|
29
|
+
activeOptions?: LinkActiveOptions;
|
|
30
|
+
/** Extra anchor props applied when the link is active. */
|
|
31
|
+
activeProps?: AnchorRest & Record<string, unknown>;
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
export type LinkProps<TTo extends string = string> = LinkBaseProps & {
|
|
35
|
+
to: TTo;
|
|
36
|
+
} & ValidateLinkTo<TTo> & LinkParamsProp<LinkTargetPathname<TTo>>;
|
|
37
|
+
/**
|
|
38
|
+
* The standard UltraModern link: a vanilla link in every respect except that
|
|
39
|
+
* it localizes canonical, language-agnostic paths automatically.
|
|
40
|
+
*
|
|
41
|
+
* - `to` accepts canonical routes (`/talks/$slug`), optionally with `#hash`
|
|
42
|
+
* and `?query` suffixes; both survive localization.
|
|
43
|
+
* - External URLs and bare `#hash` targets render a plain `<a>`.
|
|
44
|
+
* - Active state is language-invariant: a canonical `to` is active when the
|
|
45
|
+
* current location matches any localized variant of that route.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```tsx
|
|
49
|
+
* <Link to="/talks/$slug" params={{ slug: talk.slug }} hash="abstract" />
|
|
50
|
+
* <Link to="/platform" /> // -> /cs/platforma under cs
|
|
51
|
+
* <Link to="/#work-with-me" /> // cross-page hash, SPA navigation
|
|
52
|
+
* <Link to="https://ai.bleeding.dev" /> // external -> plain <a>
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export declare const Link: <TTo extends string = string>(props: LinkProps<TTo>) => React.ReactElement;
|
|
56
|
+
export default Link;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical (language-agnostic) route map.
|
|
3
|
+
*
|
|
4
|
+
* Empty by default; populated via declaration merging by the generated
|
|
5
|
+
* `register.gen.d.ts` that `@modern-js/plugin-tanstack` emits:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* declare module '@modern-js/plugin-i18n/runtime' {
|
|
9
|
+
* interface UltramodernCanonicalRoutes {
|
|
10
|
+
* '/': Record<string, never>;
|
|
11
|
+
* '/talks': Record<string, never>;
|
|
12
|
+
* '/talks/$slug': { slug: string };
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Keys are canonical route patterns in TanStack notation (`$param`,
|
|
18
|
+
* `{-$param}`); values describe the route's path params.
|
|
19
|
+
*/
|
|
20
|
+
export interface UltramodernCanonicalRoutes {
|
|
21
|
+
}
|
|
22
|
+
export type CanonicalRoutePath = keyof UltramodernCanonicalRoutes & string;
|
|
23
|
+
type HasCanonicalRoutes = [keyof UltramodernCanonicalRoutes] extends [never] ? false : true;
|
|
24
|
+
/**
|
|
25
|
+
* Targets that bypass canonical-route validation: external URLs, same-page
|
|
26
|
+
* hash anchors, and canonical paths with a `?search` and/or `#hash` suffix
|
|
27
|
+
* (the pathname part of suffixed targets is still validated).
|
|
28
|
+
*/
|
|
29
|
+
type ExternalLinkTarget = `http://${string}` | `https://${string}` | `mailto:${string}` | `tel:${string}` | `//${string}`;
|
|
30
|
+
type SuffixedCanonicalTarget = `${CanonicalRoutePath}?${string}` | `${CanonicalRoutePath}#${string}`;
|
|
31
|
+
export type AllowedLinkTarget = CanonicalRoutePath | SuffixedCanonicalTarget | ExternalLinkTarget | `#${string}`;
|
|
32
|
+
/**
|
|
33
|
+
* Validates a literal `to` against the canonical route map. Computed strings
|
|
34
|
+
* (type `string`) always pass — the escape hatch for dynamic values. When no
|
|
35
|
+
* canonical map has been generated, everything passes.
|
|
36
|
+
*/
|
|
37
|
+
export type ValidateLinkTo<TTo extends string> = HasCanonicalRoutes extends false ? unknown : string extends TTo ? unknown : TTo extends AllowedLinkTarget ? unknown : {
|
|
38
|
+
to: {
|
|
39
|
+
error: 'Not a canonical route. Authors must write language-agnostic paths; see UltramodernCanonicalRoutes.';
|
|
40
|
+
received: TTo;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/** Strip `?search`/`#hash` suffixes from a link target type. */
|
|
44
|
+
export type LinkTargetPathname<TTo extends string> = TTo extends `${infer TPath}#${string}` ? TPath extends `${infer TPure}?${string}` ? TPure : TPath : TTo extends `${infer TPath}?${string}` ? TPath : TTo;
|
|
45
|
+
/**
|
|
46
|
+
* `params` prop contract for a canonical target: required when the route has
|
|
47
|
+
* required params, optional when all params are optional, forbidden when the
|
|
48
|
+
* route has none. Non-canonical (computed/external) targets accept a loose
|
|
49
|
+
* record.
|
|
50
|
+
*/
|
|
51
|
+
export type LinkParamsProp<TPath extends string> = TPath extends CanonicalRoutePath ? UltramodernCanonicalRoutes[TPath] extends Record<string, never> ? {
|
|
52
|
+
params?: undefined;
|
|
53
|
+
} : Record<string, never> extends UltramodernCanonicalRoutes[TPath] ? {
|
|
54
|
+
params?: UltramodernCanonicalRoutes[TPath];
|
|
55
|
+
} : {
|
|
56
|
+
params: UltramodernCanonicalRoutes[TPath];
|
|
57
|
+
} : {
|
|
58
|
+
params?: Record<string, string | number | undefined>;
|
|
59
|
+
};
|
|
60
|
+
export {};
|
|
@@ -16,6 +16,10 @@ export interface I18nPluginOptions {
|
|
|
16
16
|
[key: string]: any;
|
|
17
17
|
}
|
|
18
18
|
export declare const i18nPlugin: (options: I18nPluginOptions) => RuntimePlugin;
|
|
19
|
+
export type { AllowedLinkTarget, CanonicalRoutePath, UltramodernCanonicalRoutes, } from './canonicalRoutes';
|
|
19
20
|
export { useModernI18n } from './context';
|
|
20
|
-
export { I18nLink } from './I18nLink';
|
|
21
|
+
export { I18nLink, type I18nLinkProps } from './I18nLink';
|
|
22
|
+
export { Link, type LinkActiveOptions, type LinkBaseProps, type LinkParams, type LinkProps, } from './Link';
|
|
23
|
+
export { canonicalPath, type LocalizedPathsConfig, localizePath, type UseLocalizedLocationReturn, type UseLocalizedPathsReturn, useLocalizedLocation, useLocalizedPaths, } from './localizedPaths';
|
|
24
|
+
export { buildLocalizedUrl, splitUrlTarget } from './utils';
|
|
21
25
|
export default i18nPlugin;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { LocalisedUrlsOption } from '../shared/localisedUrls';
|
|
2
|
+
export interface LocalizedPathsConfig {
|
|
3
|
+
languages: string[];
|
|
4
|
+
localisedUrls?: LocalisedUrlsOption;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Localize a canonical, language-agnostic target for the given language:
|
|
8
|
+
* adds the language prefix and applies `localisedUrls` pattern mapping.
|
|
9
|
+
* `?search`/`#hash` suffixes are preserved verbatim.
|
|
10
|
+
*/
|
|
11
|
+
export declare const localizePath: (pathname: string, language: string, config: LocalizedPathsConfig) => string;
|
|
12
|
+
/**
|
|
13
|
+
* Reverse of {@link localizePath}: strip the language prefix and map localized
|
|
14
|
+
* slugs back to the canonical pattern's path. `?search`/`#hash` suffixes are
|
|
15
|
+
* preserved verbatim.
|
|
16
|
+
*/
|
|
17
|
+
export declare const canonicalPath: (target: string, config: LocalizedPathsConfig) => string;
|
|
18
|
+
export interface UseLocalizedPathsReturn {
|
|
19
|
+
localizePath: (pathname: string, language: string) => string;
|
|
20
|
+
canonicalPath: (pathname: string) => string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Context-bound versions of {@link localizePath} and {@link canonicalPath} —
|
|
24
|
+
* the plugin configuration (languages, localisedUrls) is read from the i18n
|
|
25
|
+
* provider, so apps never copy pattern-matching helpers again.
|
|
26
|
+
*/
|
|
27
|
+
export declare const useLocalizedPaths: () => UseLocalizedPathsReturn;
|
|
28
|
+
export interface UseLocalizedLocationReturn {
|
|
29
|
+
language: string;
|
|
30
|
+
/** Canonical (language-agnostic) path of the current location. */
|
|
31
|
+
canonical: string;
|
|
32
|
+
/** Per-language hrefs for the current location, search+hash preserved. */
|
|
33
|
+
alternates: Record<string, string>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Per-language hrefs for the current location — for hreflang `<link>` tags and
|
|
37
|
+
* language switchers. SSR-safe: the location comes from the router adapter.
|
|
38
|
+
*/
|
|
39
|
+
export declare const useLocalizedLocation: () => UseLocalizedLocationReturn;
|
|
@@ -10,14 +10,23 @@ export declare const getEntryPath: () => string;
|
|
|
10
10
|
* @returns The detected language or fallback language
|
|
11
11
|
*/
|
|
12
12
|
export declare const getLanguageFromPath: (pathname: string, languages: string[], fallbackLanguage: string) => string;
|
|
13
|
+
/**
|
|
14
|
+
* Split a link target into its pathname, search and hash parts without
|
|
15
|
+
* relying on `new URL` (SSR-hot path; targets are relative).
|
|
16
|
+
*/
|
|
17
|
+
export declare const splitUrlTarget: (target: string) => {
|
|
18
|
+
pathname: string;
|
|
19
|
+
search: string;
|
|
20
|
+
hash: string;
|
|
21
|
+
};
|
|
13
22
|
/**
|
|
14
23
|
* Helper function to build localized URL
|
|
15
|
-
* @param
|
|
24
|
+
* @param target - The language-agnostic target; may include `?search` and `#hash`
|
|
16
25
|
* @param language - The target language
|
|
17
26
|
* @param languages - Array of supported languages
|
|
18
|
-
* @returns The localized URL path
|
|
27
|
+
* @returns The localized URL path with search and hash re-appended verbatim
|
|
19
28
|
*/
|
|
20
|
-
export declare const buildLocalizedUrl: (
|
|
29
|
+
export declare const buildLocalizedUrl: (target: string, language: string, languages: string[], localisedUrls?: boolean | LocalisedUrlsMap) => string;
|
|
21
30
|
export declare const detectLanguageFromPath: (pathname: string, languages: string[], localePathRedirect: boolean) => {
|
|
22
31
|
detected: boolean;
|
|
23
32
|
language?: string;
|
|
@@ -10,4 +10,12 @@ export declare const normalisePathPattern: (path: string) => string;
|
|
|
10
10
|
export declare const resolveLocalisedUrlsConfig: (option: LocalisedUrlsOption | undefined) => ResolvedLocalisedUrlsConfig;
|
|
11
11
|
export declare const validateLocalisedUrls: (routes: (NestedRouteForCli | PageRoute)[], languages: string[], localisedUrls: LocalisedUrlsMap) => void;
|
|
12
12
|
export declare const applyLocalisedUrlsToRoutes: (routes: (NestedRouteForCli | PageRoute)[], languages: string[], localisedUrls: LocalisedUrlsMap) => (NestedRouteForCli | PageRoute)[];
|
|
13
|
+
export declare const matchPathPattern: (pathname: string, pattern: string) => Record<string, string> | null;
|
|
14
|
+
export declare const buildPathFromPattern: (pattern: string, params: Record<string, string>) => string;
|
|
13
15
|
export declare const resolveLocalisedPath: (pathname: string, targetLanguage: string, languages: string[], localisedUrls: LocalisedUrlsMap) => string;
|
|
16
|
+
/**
|
|
17
|
+
* Reverse-map a language-specific pathname (without language prefix) back to
|
|
18
|
+
* the canonical, language-agnostic path: localized slug patterns are matched
|
|
19
|
+
* against every language variant and rebuilt from the canonical map key.
|
|
20
|
+
*/
|
|
21
|
+
export declare const resolveCanonicalLocalisedPath: (pathname: string, languages: string[], localisedUrls: LocalisedUrlsMap) => string;
|