@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.12 → 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/cli/index.js +32 -5
- package/dist/cjs/runtime/I18nLink.js +17 -28
- package/dist/cjs/runtime/Link.js +252 -0
- package/dist/cjs/runtime/canonicalRoutes.js +18 -0
- package/dist/cjs/runtime/context.js +41 -10
- package/dist/cjs/runtime/hooks.js +17 -10
- package/dist/cjs/runtime/i18n/backend/config.js +9 -5
- package/dist/cjs/runtime/i18n/backend/defaults.js +15 -10
- package/dist/cjs/runtime/i18n/backend/defaults.node.js +16 -11
- package/dist/cjs/runtime/i18n/backend/index.js +9 -5
- package/dist/cjs/runtime/i18n/backend/middleware.common.js +9 -5
- package/dist/cjs/runtime/i18n/backend/middleware.js +9 -5
- package/dist/cjs/runtime/i18n/backend/middleware.node.js +13 -9
- package/dist/cjs/runtime/i18n/backend/sdk-backend.js +9 -5
- package/dist/cjs/runtime/i18n/backend/sdk-event.js +16 -11
- package/dist/cjs/runtime/i18n/detection/config.js +9 -5
- package/dist/cjs/runtime/i18n/detection/index.js +9 -5
- package/dist/cjs/runtime/i18n/detection/middleware.js +9 -5
- package/dist/cjs/runtime/i18n/detection/middleware.node.js +9 -5
- package/dist/cjs/runtime/i18n/index.js +9 -5
- package/dist/cjs/runtime/i18n/instance.js +17 -37
- package/dist/cjs/runtime/i18n/react-i18next.js +53 -0
- package/dist/cjs/runtime/i18n/utils.js +9 -17
- package/dist/cjs/runtime/index.js +50 -15
- package/dist/cjs/runtime/localizedPaths.js +105 -0
- package/dist/cjs/runtime/routerAdapter.js +167 -0
- package/dist/cjs/runtime/utils.js +87 -97
- package/dist/cjs/server/index.js +69 -13
- package/dist/cjs/shared/deepMerge.js +12 -8
- package/dist/cjs/shared/detection.js +9 -5
- package/dist/cjs/shared/localisedUrls.js +271 -0
- package/dist/cjs/shared/utils.js +15 -11
- package/dist/esm/cli/index.mjs +23 -0
- package/dist/esm/runtime/I18nLink.mjs +7 -22
- package/dist/esm/runtime/Link.mjs +209 -0
- package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
- package/dist/esm/runtime/context.mjs +34 -7
- package/dist/esm/runtime/hooks.mjs +9 -6
- package/dist/esm/runtime/i18n/backend/defaults.mjs +1 -1
- package/dist/esm/runtime/i18n/backend/defaults.node.mjs +2 -2
- package/dist/esm/runtime/i18n/backend/middleware.node.mjs +3 -3
- package/dist/esm/runtime/i18n/instance.mjs +1 -19
- package/dist/esm/runtime/i18n/react-i18next.mjs +15 -0
- package/dist/esm/runtime/i18n/utils.mjs +0 -12
- package/dist/esm/runtime/index.mjs +23 -13
- package/dist/esm/runtime/localizedPaths.mjs +58 -0
- package/dist/esm/runtime/routerAdapter.mjs +129 -0
- package/dist/esm/runtime/utils.mjs +25 -30
- package/dist/esm/server/index.mjs +53 -7
- package/dist/esm/shared/localisedUrls.mjs +212 -0
- package/dist/esm-node/cli/index.mjs +23 -0
- package/dist/esm-node/runtime/I18nLink.mjs +7 -22
- package/dist/esm-node/runtime/Link.mjs +210 -0
- package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
- package/dist/esm-node/runtime/context.mjs +34 -7
- package/dist/esm-node/runtime/hooks.mjs +9 -6
- package/dist/esm-node/runtime/i18n/backend/defaults.mjs +1 -1
- package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +2 -2
- package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +3 -3
- package/dist/esm-node/runtime/i18n/instance.mjs +1 -19
- package/dist/esm-node/runtime/i18n/react-i18next.mjs +16 -0
- package/dist/esm-node/runtime/i18n/utils.mjs +0 -12
- package/dist/esm-node/runtime/index.mjs +23 -13
- package/dist/esm-node/runtime/localizedPaths.mjs +59 -0
- package/dist/esm-node/runtime/routerAdapter.mjs +130 -0
- package/dist/esm-node/runtime/utils.mjs +25 -30
- package/dist/esm-node/server/index.mjs +53 -7
- package/dist/esm-node/shared/localisedUrls.mjs +213 -0
- package/dist/types/cli/index.d.ts +1 -0
- package/dist/types/runtime/I18nLink.d.ts +6 -0
- package/dist/types/runtime/Link.d.ts +56 -0
- package/dist/types/runtime/canonicalRoutes.d.ts +60 -0
- package/dist/types/runtime/context.d.ts +3 -0
- package/dist/types/runtime/hooks.d.ts +4 -2
- package/dist/types/runtime/i18n/backend/middleware.node.d.ts +1 -1
- package/dist/types/runtime/i18n/instance.d.ts +4 -6
- package/dist/types/runtime/i18n/react-i18next.d.ts +7 -0
- package/dist/types/runtime/index.d.ts +6 -1
- package/dist/types/runtime/localizedPaths.d.ts +39 -0
- package/dist/types/runtime/routerAdapter.d.ts +26 -0
- package/dist/types/runtime/types.d.ts +1 -1
- package/dist/types/runtime/utils.d.ts +13 -9
- package/dist/types/server/index.d.ts +6 -0
- package/dist/types/shared/localisedUrls.d.ts +21 -0
- package/dist/types/shared/type.d.ts +12 -0
- package/package.json +24 -28
- package/rstest.config.mts +39 -0
- package/src/cli/index.ts +44 -1
- package/src/runtime/I18nLink.tsx +14 -51
- package/src/runtime/Link.tsx +414 -0
- package/src/runtime/canonicalRoutes.ts +93 -0
- package/src/runtime/context.tsx +45 -7
- package/src/runtime/hooks.ts +13 -4
- package/src/runtime/i18n/backend/defaults.node.ts +2 -2
- package/src/runtime/i18n/backend/defaults.ts +3 -1
- package/src/runtime/i18n/backend/middleware.node.ts +1 -1
- package/src/runtime/i18n/instance.ts +3 -30
- package/src/runtime/i18n/react-i18next.ts +25 -0
- package/src/runtime/i18n/utils.ts +4 -26
- package/src/runtime/index.tsx +47 -12
- package/src/runtime/localizedPaths.ts +118 -0
- package/src/runtime/routerAdapter.tsx +333 -0
- package/src/runtime/types.ts +1 -1
- package/src/runtime/utils.ts +44 -37
- package/src/server/index.ts +117 -10
- package/src/shared/localisedUrls.ts +453 -0
- package/src/shared/type.ts +12 -0
- package/tests/i18nUtils.test.ts +52 -0
- package/tests/link.test.tsx +475 -0
- package/tests/linkTypes.test.ts +28 -0
- package/tests/localisedUrls.test.ts +312 -0
- package/tests/routerAdapter.test.tsx +452 -0
- package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
- package/tests/type-fixture/tsconfig.json +15 -0
- package/dist/esm/rslib-runtime.mjs +0 -18
- package/dist/esm-node/rslib-runtime.mjs +0 -19
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import type {
|
|
4
|
+
LinkParamsProp,
|
|
5
|
+
LinkTargetPathname,
|
|
6
|
+
ValidateLinkTo,
|
|
7
|
+
} from './canonicalRoutes';
|
|
8
|
+
import { useModernI18n } from './context';
|
|
9
|
+
import { canonicalPath, type LocalizedPathsConfig } from './localizedPaths';
|
|
10
|
+
import { useI18nRouterAdapter } from './routerAdapter';
|
|
11
|
+
import { buildLocalizedUrl, splitUrlTarget } from './utils';
|
|
12
|
+
|
|
13
|
+
const EXTERNAL_TARGET_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
14
|
+
|
|
15
|
+
const warnedTargets = new Set<string>();
|
|
16
|
+
|
|
17
|
+
const warnOnce = (key: string, message: string) => {
|
|
18
|
+
if (process.env.NODE_ENV !== 'development' || warnedTargets.has(key)) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
warnedTargets.add(key);
|
|
22
|
+
console.warn(message);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type LinkParams = Record<string, string | number | undefined>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Interpolate `$param`, `:param`, optional (`{-$param}` / `:param?`) and splat
|
|
29
|
+
* (`$` / `*`) segments with concrete values before localization, so
|
|
30
|
+
* pattern-mapped slugs localize correctly.
|
|
31
|
+
*/
|
|
32
|
+
export const interpolateRouteParams = (
|
|
33
|
+
pathname: string,
|
|
34
|
+
params?: LinkParams,
|
|
35
|
+
): string => {
|
|
36
|
+
if (!/[$:*{]/.test(pathname)) {
|
|
37
|
+
return pathname;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const resolveParam = (name: string): string | undefined => {
|
|
41
|
+
const value = params?.[name];
|
|
42
|
+
return value === undefined ? undefined : String(value);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const segments = pathname
|
|
46
|
+
.split('/')
|
|
47
|
+
.map(segment => {
|
|
48
|
+
if (!segment) {
|
|
49
|
+
return segment;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (segment.startsWith('{-$') && segment.endsWith('}')) {
|
|
53
|
+
const value = resolveParam(segment.slice(3, -1));
|
|
54
|
+
return value === undefined ? null : encodeURIComponent(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (segment === '$' || segment === '*') {
|
|
58
|
+
const value = resolveParam('_splat') ?? resolveParam('*');
|
|
59
|
+
return value === undefined
|
|
60
|
+
? null
|
|
61
|
+
: value.split('/').map(encodeURIComponent).join('/');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (segment.startsWith('$')) {
|
|
65
|
+
const value = resolveParam(segment.slice(1));
|
|
66
|
+
if (value === undefined) {
|
|
67
|
+
warnOnce(
|
|
68
|
+
`missing-param:${pathname}:${segment}`,
|
|
69
|
+
`[plugin-i18n] <Link to="${pathname}"> is missing required param "${segment.slice(1)}".`,
|
|
70
|
+
);
|
|
71
|
+
return segment;
|
|
72
|
+
}
|
|
73
|
+
return encodeURIComponent(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (segment.startsWith(':')) {
|
|
77
|
+
const optional = segment.endsWith('?');
|
|
78
|
+
const name = segment.slice(1, optional ? -1 : undefined);
|
|
79
|
+
const value = resolveParam(name);
|
|
80
|
+
if (value === undefined) {
|
|
81
|
+
if (optional) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
warnOnce(
|
|
85
|
+
`missing-param:${pathname}:${segment}`,
|
|
86
|
+
`[plugin-i18n] <Link to="${pathname}"> is missing required param "${name}".`,
|
|
87
|
+
);
|
|
88
|
+
return segment;
|
|
89
|
+
}
|
|
90
|
+
return encodeURIComponent(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return segment;
|
|
94
|
+
})
|
|
95
|
+
.filter(segment => segment !== null);
|
|
96
|
+
|
|
97
|
+
return segments.join('/') || '/';
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export interface LinkActiveOptions {
|
|
101
|
+
/**
|
|
102
|
+
* `true`: active only when the location matches the target exactly.
|
|
103
|
+
* `false`: also active when the location is nested under the target.
|
|
104
|
+
* Defaults to prefix matching, except for `/` which defaults to exact.
|
|
105
|
+
*/
|
|
106
|
+
exact?: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type AnchorRest = Omit<
|
|
110
|
+
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
|
111
|
+
'href' | 'children'
|
|
112
|
+
>;
|
|
113
|
+
|
|
114
|
+
export interface LinkBaseProps extends AnchorRest {
|
|
115
|
+
children?: React.ReactNode;
|
|
116
|
+
/** Hash fragment without the leading `#`. Overrides a `#hash` inside `to`. */
|
|
117
|
+
hash?: string;
|
|
118
|
+
/** Search params. Object form is passed natively to TanStack Link. */
|
|
119
|
+
search?: string | Record<string, unknown>;
|
|
120
|
+
hashScrollIntoView?: boolean | ScrollIntoViewOptions;
|
|
121
|
+
replace?: boolean;
|
|
122
|
+
prefetch?: 'intent' | 'render' | 'viewport' | 'none';
|
|
123
|
+
preload?: unknown;
|
|
124
|
+
activeOptions?: LinkActiveOptions;
|
|
125
|
+
/** Extra anchor props applied when the link is active. */
|
|
126
|
+
activeProps?: AnchorRest & Record<string, unknown>;
|
|
127
|
+
[key: string]: unknown;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type LinkProps<TTo extends string = string> = LinkBaseProps & {
|
|
131
|
+
to: TTo;
|
|
132
|
+
} & ValidateLinkTo<TTo> &
|
|
133
|
+
LinkParamsProp<LinkTargetPathname<TTo>>;
|
|
134
|
+
|
|
135
|
+
const normalizeSearch = (
|
|
136
|
+
search: string | Record<string, unknown> | undefined,
|
|
137
|
+
searchFromTo: string,
|
|
138
|
+
): {
|
|
139
|
+
searchString: string;
|
|
140
|
+
searchObject: Record<string, string> | undefined;
|
|
141
|
+
} => {
|
|
142
|
+
if (search && typeof search === 'object') {
|
|
143
|
+
const entries = Object.entries(search).filter(
|
|
144
|
+
([, value]) => value !== undefined && value !== null,
|
|
145
|
+
);
|
|
146
|
+
const searchObject = Object.fromEntries(
|
|
147
|
+
entries.map(([key, value]) => [key, String(value)]),
|
|
148
|
+
);
|
|
149
|
+
const params = new URLSearchParams(searchObject);
|
|
150
|
+
const serialized = params.toString();
|
|
151
|
+
return {
|
|
152
|
+
searchString: serialized ? `?${serialized}` : '',
|
|
153
|
+
searchObject,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const raw = typeof search === 'string' && search ? search : searchFromTo;
|
|
158
|
+
if (!raw) {
|
|
159
|
+
return { searchString: '', searchObject: undefined };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const searchString = raw.startsWith('?') ? raw : `?${raw}`;
|
|
163
|
+
const searchObject: Record<string, string> = {};
|
|
164
|
+
new URLSearchParams(searchString).forEach((value, key) => {
|
|
165
|
+
searchObject[key] = value;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return { searchString, searchObject };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const splitActiveProps = (
|
|
172
|
+
active: boolean,
|
|
173
|
+
activeProps?: LinkBaseProps['activeProps'],
|
|
174
|
+
) => {
|
|
175
|
+
if (!active || !activeProps) {
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
return activeProps;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const mergeClassNames = (...values: Array<unknown>): string | undefined => {
|
|
182
|
+
const classNames = values.filter(
|
|
183
|
+
(value): value is string => typeof value === 'string' && value.length > 0,
|
|
184
|
+
);
|
|
185
|
+
return classNames.length > 0 ? classNames.join(' ') : undefined;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The standard UltraModern link: a vanilla link in every respect except that
|
|
190
|
+
* it localizes canonical, language-agnostic paths automatically.
|
|
191
|
+
*
|
|
192
|
+
* - `to` accepts canonical routes (`/talks/$slug`), optionally with `#hash`
|
|
193
|
+
* and `?query` suffixes; both survive localization.
|
|
194
|
+
* - External URLs and bare `#hash` targets render a plain `<a>`.
|
|
195
|
+
* - Active state is language-invariant: a canonical `to` is active when the
|
|
196
|
+
* current location matches any localized variant of that route.
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* ```tsx
|
|
200
|
+
* <Link to="/talks/$slug" params={{ slug: talk.slug }} hash="abstract" />
|
|
201
|
+
* <Link to="/platform" /> // -> /cs/platforma under cs
|
|
202
|
+
* <Link to="/#work-with-me" /> // cross-page hash, SPA navigation
|
|
203
|
+
* <Link to="https://ai.bleeding.dev" /> // external -> plain <a>
|
|
204
|
+
* ```
|
|
205
|
+
*/
|
|
206
|
+
export const Link = <TTo extends string = string>(
|
|
207
|
+
props: LinkProps<TTo>,
|
|
208
|
+
): React.ReactElement => {
|
|
209
|
+
const {
|
|
210
|
+
to,
|
|
211
|
+
params,
|
|
212
|
+
children,
|
|
213
|
+
hash: hashProp,
|
|
214
|
+
search: searchProp,
|
|
215
|
+
hashScrollIntoView,
|
|
216
|
+
activeOptions,
|
|
217
|
+
activeProps,
|
|
218
|
+
...rest
|
|
219
|
+
} = props as LinkBaseProps & { to: string; params?: LinkParams };
|
|
220
|
+
|
|
221
|
+
const adapter = useI18nRouterAdapter();
|
|
222
|
+
const { language, supportedLanguages, localisedUrls } = useModernI18n();
|
|
223
|
+
|
|
224
|
+
const config: LocalizedPathsConfig = {
|
|
225
|
+
languages: supportedLanguages,
|
|
226
|
+
localisedUrls,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const isExternal = EXTERNAL_TARGET_RE.test(to);
|
|
230
|
+
const isBareHash = to.startsWith('#');
|
|
231
|
+
|
|
232
|
+
const target = useMemo(() => {
|
|
233
|
+
if (isExternal || isBareHash) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const {
|
|
238
|
+
pathname,
|
|
239
|
+
search: searchFromTo,
|
|
240
|
+
hash: hashFromTo,
|
|
241
|
+
} = splitUrlTarget(to);
|
|
242
|
+
const interpolated = interpolateRouteParams(pathname || '/', params);
|
|
243
|
+
|
|
244
|
+
const firstSegment = interpolated.split('/').filter(Boolean)[0];
|
|
245
|
+
if (firstSegment && supportedLanguages.includes(firstSegment)) {
|
|
246
|
+
warnOnce(
|
|
247
|
+
`lang-prefix:${to}`,
|
|
248
|
+
`[plugin-i18n] <Link to="${to}"> starts with a language prefix. ` +
|
|
249
|
+
'Write language-agnostic canonical paths; the Link localizes them automatically.',
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const localizedPathname = buildLocalizedUrl(
|
|
254
|
+
interpolated,
|
|
255
|
+
language,
|
|
256
|
+
supportedLanguages,
|
|
257
|
+
localisedUrls,
|
|
258
|
+
);
|
|
259
|
+
const hash = hashProp ?? (hashFromTo ? hashFromTo.slice(1) : '');
|
|
260
|
+
const { searchString, searchObject } = normalizeSearch(
|
|
261
|
+
searchProp,
|
|
262
|
+
searchFromTo,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
canonicalPathname: interpolated,
|
|
267
|
+
localizedPathname,
|
|
268
|
+
hash,
|
|
269
|
+
searchString,
|
|
270
|
+
searchObject,
|
|
271
|
+
href: `${localizedPathname}${searchString}${hash ? `#${hash}` : ''}`,
|
|
272
|
+
};
|
|
273
|
+
}, [
|
|
274
|
+
to,
|
|
275
|
+
params,
|
|
276
|
+
hashProp,
|
|
277
|
+
searchProp,
|
|
278
|
+
isExternal,
|
|
279
|
+
isBareHash,
|
|
280
|
+
language,
|
|
281
|
+
supportedLanguages,
|
|
282
|
+
localisedUrls,
|
|
283
|
+
]);
|
|
284
|
+
|
|
285
|
+
const isActive = useMemo(() => {
|
|
286
|
+
if (!target || !adapter.location) {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const current = canonicalPath(adapter.location.pathname, config);
|
|
291
|
+
const targetCanonical = canonicalPath(target.canonicalPathname, config);
|
|
292
|
+
const exact = activeOptions?.exact ?? targetCanonical === '/';
|
|
293
|
+
|
|
294
|
+
if (current === targetCanonical) {
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
if (exact) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
return current.startsWith(
|
|
301
|
+
targetCanonical === '/' ? '/' : `${targetCanonical}/`,
|
|
302
|
+
);
|
|
303
|
+
}, [
|
|
304
|
+
target,
|
|
305
|
+
adapter.location,
|
|
306
|
+
activeOptions?.exact,
|
|
307
|
+
supportedLanguages,
|
|
308
|
+
localisedUrls,
|
|
309
|
+
]);
|
|
310
|
+
|
|
311
|
+
const resolvedActiveProps = splitActiveProps(isActive, activeProps);
|
|
312
|
+
const activeAttributes = isActive
|
|
313
|
+
? {
|
|
314
|
+
'data-status': 'active',
|
|
315
|
+
'aria-current': (rest['aria-current'] ??
|
|
316
|
+
resolvedActiveProps['aria-current'] ??
|
|
317
|
+
'page') as React.AriaAttributes['aria-current'],
|
|
318
|
+
}
|
|
319
|
+
: {};
|
|
320
|
+
|
|
321
|
+
// External targets and same-page anchors are vanilla links.
|
|
322
|
+
if (!target) {
|
|
323
|
+
const {
|
|
324
|
+
prefetch: _prefetch,
|
|
325
|
+
preload: _preload,
|
|
326
|
+
replace: _replace,
|
|
327
|
+
...anchorProps
|
|
328
|
+
} = rest;
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<a href={to} {...anchorProps}>
|
|
332
|
+
{children}
|
|
333
|
+
</a>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const { Link: RouterLink, hasRouter, framework } = adapter;
|
|
338
|
+
|
|
339
|
+
if (!hasRouter || !RouterLink) {
|
|
340
|
+
const {
|
|
341
|
+
prefetch: _prefetch,
|
|
342
|
+
preload: _preload,
|
|
343
|
+
replace: _replace,
|
|
344
|
+
...anchorProps
|
|
345
|
+
} = rest;
|
|
346
|
+
const {
|
|
347
|
+
className: activeClassName,
|
|
348
|
+
style: activeStyle,
|
|
349
|
+
...activeRest
|
|
350
|
+
} = resolvedActiveProps;
|
|
351
|
+
|
|
352
|
+
return (
|
|
353
|
+
<a
|
|
354
|
+
href={target.href}
|
|
355
|
+
{...anchorProps}
|
|
356
|
+
{...activeRest}
|
|
357
|
+
{...activeAttributes}
|
|
358
|
+
className={mergeClassNames(rest.className, activeClassName)}
|
|
359
|
+
style={{
|
|
360
|
+
...(rest.style as React.CSSProperties | undefined),
|
|
361
|
+
...(activeStyle as React.CSSProperties | undefined),
|
|
362
|
+
}}
|
|
363
|
+
>
|
|
364
|
+
{children}
|
|
365
|
+
</a>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const {
|
|
370
|
+
className: activeClassName,
|
|
371
|
+
style: activeStyle,
|
|
372
|
+
...activeRest
|
|
373
|
+
} = resolvedActiveProps;
|
|
374
|
+
const mergedClassName = mergeClassNames(rest.className, activeClassName);
|
|
375
|
+
const mergedStyle = {
|
|
376
|
+
...(rest.style as React.CSSProperties | undefined),
|
|
377
|
+
...(activeStyle as React.CSSProperties | undefined),
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
if (framework === 'tanstack') {
|
|
381
|
+
// Pass hash/search natively: string-concatenated targets silently break
|
|
382
|
+
// TanStack navigation.
|
|
383
|
+
return (
|
|
384
|
+
<RouterLink
|
|
385
|
+
to={target.localizedPathname}
|
|
386
|
+
{...(target.searchObject ? { search: target.searchObject } : {})}
|
|
387
|
+
{...(target.hash ? { hash: target.hash } : {})}
|
|
388
|
+
{...(hashScrollIntoView === undefined ? {} : { hashScrollIntoView })}
|
|
389
|
+
{...rest}
|
|
390
|
+
{...activeRest}
|
|
391
|
+
{...activeAttributes}
|
|
392
|
+
className={mergedClassName}
|
|
393
|
+
style={mergedStyle}
|
|
394
|
+
>
|
|
395
|
+
{children}
|
|
396
|
+
</RouterLink>
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return (
|
|
401
|
+
<RouterLink
|
|
402
|
+
to={target.href}
|
|
403
|
+
{...rest}
|
|
404
|
+
{...activeRest}
|
|
405
|
+
{...activeAttributes}
|
|
406
|
+
className={mergedClassName}
|
|
407
|
+
style={mergedStyle}
|
|
408
|
+
>
|
|
409
|
+
{children}
|
|
410
|
+
</RouterLink>
|
|
411
|
+
);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
export default Link;
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
// biome-ignore lint/suspicious/noEmptyInterface: augmented by generated code
|
|
21
|
+
export interface UltramodernCanonicalRoutes {}
|
|
22
|
+
|
|
23
|
+
export type CanonicalRoutePath = keyof UltramodernCanonicalRoutes & string;
|
|
24
|
+
|
|
25
|
+
type HasCanonicalRoutes = [keyof UltramodernCanonicalRoutes] extends [never]
|
|
26
|
+
? false
|
|
27
|
+
: true;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Targets that bypass canonical-route validation: external URLs, same-page
|
|
31
|
+
* hash anchors, and canonical paths with a `?search` and/or `#hash` suffix
|
|
32
|
+
* (the pathname part of suffixed targets is still validated).
|
|
33
|
+
*/
|
|
34
|
+
type ExternalLinkTarget =
|
|
35
|
+
| `http://${string}`
|
|
36
|
+
| `https://${string}`
|
|
37
|
+
| `mailto:${string}`
|
|
38
|
+
| `tel:${string}`
|
|
39
|
+
| `//${string}`;
|
|
40
|
+
|
|
41
|
+
type SuffixedCanonicalTarget =
|
|
42
|
+
| `${CanonicalRoutePath}?${string}`
|
|
43
|
+
| `${CanonicalRoutePath}#${string}`;
|
|
44
|
+
|
|
45
|
+
export type AllowedLinkTarget =
|
|
46
|
+
| CanonicalRoutePath
|
|
47
|
+
| SuffixedCanonicalTarget
|
|
48
|
+
| ExternalLinkTarget
|
|
49
|
+
| `#${string}`;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validates a literal `to` against the canonical route map. Computed strings
|
|
53
|
+
* (type `string`) always pass — the escape hatch for dynamic values. When no
|
|
54
|
+
* canonical map has been generated, everything passes.
|
|
55
|
+
*/
|
|
56
|
+
export type ValidateLinkTo<TTo extends string> =
|
|
57
|
+
HasCanonicalRoutes extends false
|
|
58
|
+
? unknown
|
|
59
|
+
: string extends TTo
|
|
60
|
+
? unknown
|
|
61
|
+
: TTo extends AllowedLinkTarget
|
|
62
|
+
? unknown
|
|
63
|
+
: {
|
|
64
|
+
to: {
|
|
65
|
+
error: 'Not a canonical route. Authors must write language-agnostic paths; see UltramodernCanonicalRoutes.';
|
|
66
|
+
received: TTo;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Strip `?search`/`#hash` suffixes from a link target type. */
|
|
71
|
+
export type LinkTargetPathname<TTo extends string> =
|
|
72
|
+
TTo extends `${infer TPath}#${string}`
|
|
73
|
+
? TPath extends `${infer TPure}?${string}`
|
|
74
|
+
? TPure
|
|
75
|
+
: TPath
|
|
76
|
+
: TTo extends `${infer TPath}?${string}`
|
|
77
|
+
? TPath
|
|
78
|
+
: TTo;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `params` prop contract for a canonical target: required when the route has
|
|
82
|
+
* required params, optional when all params are optional, forbidden when the
|
|
83
|
+
* route has none. Non-canonical (computed/external) targets accept a loose
|
|
84
|
+
* record.
|
|
85
|
+
*/
|
|
86
|
+
export type LinkParamsProp<TPath extends string> =
|
|
87
|
+
TPath extends CanonicalRoutePath
|
|
88
|
+
? UltramodernCanonicalRoutes[TPath] extends Record<string, never>
|
|
89
|
+
? { params?: undefined }
|
|
90
|
+
: Record<string, never> extends UltramodernCanonicalRoutes[TPath]
|
|
91
|
+
? { params?: UltramodernCanonicalRoutes[TPath] }
|
|
92
|
+
: { params: UltramodernCanonicalRoutes[TPath] }
|
|
93
|
+
: { params?: Record<string, string | number | undefined> };
|
package/src/runtime/context.tsx
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { isBrowser } from '@modern-js/runtime';
|
|
2
2
|
import type { FC, ReactNode } from 'react';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import type { LocalisedUrlsOption } from '../shared/localisedUrls';
|
|
4
11
|
import type { I18nInstance } from './i18n';
|
|
5
12
|
import type { SdkBackend } from './i18n/backend/sdk-backend';
|
|
6
13
|
import { cacheUserLanguage } from './i18n/detection';
|
|
14
|
+
import { useI18nRouterAdapter } from './routerAdapter';
|
|
7
15
|
import {
|
|
8
16
|
buildLocalizedUrl,
|
|
9
17
|
detectLanguageFromPath,
|
|
10
18
|
getEntryPath,
|
|
11
19
|
shouldIgnoreRedirect,
|
|
12
|
-
useRouterHooks,
|
|
13
20
|
} from './utils';
|
|
14
21
|
|
|
15
22
|
export interface ModernI18nContextValue {
|
|
@@ -20,6 +27,7 @@ export interface ModernI18nContextValue {
|
|
|
20
27
|
languages?: string[];
|
|
21
28
|
localePathRedirect?: boolean;
|
|
22
29
|
ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
|
|
30
|
+
localisedUrls?: LocalisedUrlsOption;
|
|
23
31
|
// Callback to update language in context
|
|
24
32
|
updateLanguage?: (newLang: string) => void;
|
|
25
33
|
}
|
|
@@ -47,6 +55,7 @@ export interface UseModernI18nReturn {
|
|
|
47
55
|
changeLanguage: (newLang: string) => Promise<void>;
|
|
48
56
|
i18nInstance: I18nInstance;
|
|
49
57
|
supportedLanguages: string[];
|
|
58
|
+
localisedUrls?: LocalisedUrlsOption;
|
|
50
59
|
isLanguageSupported: (lang: string) => boolean;
|
|
51
60
|
// Indicates if translation resources for current language are ready to use
|
|
52
61
|
isResourcesReady: boolean;
|
|
@@ -77,15 +86,40 @@ export const useModernI18n = (): UseModernI18nReturn => {
|
|
|
77
86
|
languages,
|
|
78
87
|
localePathRedirect,
|
|
79
88
|
ignoreRedirectRoutes,
|
|
89
|
+
localisedUrls,
|
|
80
90
|
updateLanguage,
|
|
81
91
|
} = context;
|
|
82
92
|
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
const { navigate, location, hasRouter } = useI18nRouterAdapter();
|
|
94
|
+
|
|
95
|
+
const pathLanguage = useMemo(() => {
|
|
96
|
+
if (!localePathRedirect || !location?.pathname) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const detected = detectLanguageFromPath(
|
|
100
|
+
location.pathname,
|
|
101
|
+
languages || [],
|
|
102
|
+
localePathRedirect,
|
|
103
|
+
);
|
|
104
|
+
return detected.detected ? detected.language : undefined;
|
|
105
|
+
}, [languages, localePathRedirect, location?.pathname]);
|
|
106
|
+
|
|
107
|
+
const currentLanguage = pathLanguage || contextLanguage;
|
|
85
108
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!pathLanguage || pathLanguage === contextLanguage) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
updateLanguage?.(pathLanguage);
|
|
115
|
+
i18nInstance?.setLang?.(pathLanguage);
|
|
116
|
+
void i18nInstance?.changeLanguage?.(pathLanguage);
|
|
117
|
+
|
|
118
|
+
if (isBrowser()) {
|
|
119
|
+
const detectionOptions = i18nInstance.options?.detection;
|
|
120
|
+
cacheUserLanguage(i18nInstance, pathLanguage, detectionOptions);
|
|
121
|
+
}
|
|
122
|
+
}, [contextLanguage, i18nInstance, pathLanguage, updateLanguage]);
|
|
89
123
|
|
|
90
124
|
/**
|
|
91
125
|
* Changes the current language and updates the URL accordingly.
|
|
@@ -147,6 +181,7 @@ export const useModernI18n = (): UseModernI18nReturn => {
|
|
|
147
181
|
relativePath,
|
|
148
182
|
newLang,
|
|
149
183
|
languages || [],
|
|
184
|
+
localisedUrls,
|
|
150
185
|
);
|
|
151
186
|
const newUrl =
|
|
152
187
|
entryPath + newPath + location.search + location.hash;
|
|
@@ -181,6 +216,7 @@ export const useModernI18n = (): UseModernI18nReturn => {
|
|
|
181
216
|
relativePath,
|
|
182
217
|
newLang,
|
|
183
218
|
languages || [],
|
|
219
|
+
localisedUrls,
|
|
184
220
|
);
|
|
185
221
|
const newUrl =
|
|
186
222
|
entryPath +
|
|
@@ -206,6 +242,7 @@ export const useModernI18n = (): UseModernI18nReturn => {
|
|
|
206
242
|
updateLanguage,
|
|
207
243
|
localePathRedirect,
|
|
208
244
|
ignoreRedirectRoutes,
|
|
245
|
+
localisedUrls,
|
|
209
246
|
languages,
|
|
210
247
|
hasRouter,
|
|
211
248
|
navigate,
|
|
@@ -275,6 +312,7 @@ export const useModernI18n = (): UseModernI18nReturn => {
|
|
|
275
312
|
changeLanguage,
|
|
276
313
|
i18nInstance,
|
|
277
314
|
supportedLanguages: languages || [],
|
|
315
|
+
localisedUrls,
|
|
278
316
|
isLanguageSupported,
|
|
279
317
|
isResourcesReady,
|
|
280
318
|
};
|
package/src/runtime/hooks.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { TRuntimeContext } from '@modern-js/runtime';
|
|
|
2
2
|
import { isBrowser } from '@modern-js/runtime';
|
|
3
3
|
import type React from 'react';
|
|
4
4
|
import { useEffect, useRef } from 'react';
|
|
5
|
+
import type { LocalisedUrlsOption } from '../shared/localisedUrls';
|
|
5
6
|
import type { I18nInstance } from './i18n';
|
|
6
7
|
import {
|
|
7
8
|
getI18nSdkBackendId,
|
|
@@ -9,13 +10,13 @@ import {
|
|
|
9
10
|
type I18nSdkResourcesLoadedEventDetail,
|
|
10
11
|
} from './i18n/backend/sdk-event';
|
|
11
12
|
import { cacheUserLanguage } from './i18n/detection';
|
|
13
|
+
import { useI18nRouterAdapter } from './routerAdapter';
|
|
12
14
|
import {
|
|
13
15
|
buildLocalizedUrl,
|
|
14
16
|
detectLanguageFromPath,
|
|
15
17
|
getEntryPath,
|
|
16
18
|
getPathname,
|
|
17
19
|
shouldIgnoreRedirect,
|
|
18
|
-
useRouterHooks,
|
|
19
20
|
} from './utils';
|
|
20
21
|
|
|
21
22
|
interface RuntimeContextWithI18n extends TRuntimeContext {
|
|
@@ -41,6 +42,7 @@ export function createContextValue(
|
|
|
41
42
|
languages: string[],
|
|
42
43
|
localePathRedirect: boolean,
|
|
43
44
|
ignoreRedirectRoutes: string[] | ((pathname: string) => boolean) | undefined,
|
|
45
|
+
localisedUrls: LocalisedUrlsOption | undefined,
|
|
44
46
|
setLang: (lang: string) => void,
|
|
45
47
|
) {
|
|
46
48
|
const instance = i18nInstance || createMinimalI18nInstance(lang);
|
|
@@ -51,6 +53,7 @@ export function createContextValue(
|
|
|
51
53
|
languages,
|
|
52
54
|
localePathRedirect,
|
|
53
55
|
ignoreRedirectRoutes,
|
|
56
|
+
localisedUrls,
|
|
54
57
|
updateLanguage: setLang,
|
|
55
58
|
};
|
|
56
59
|
}
|
|
@@ -162,10 +165,10 @@ export function useClientSideRedirect(
|
|
|
162
165
|
languages: string[],
|
|
163
166
|
fallbackLanguage: string,
|
|
164
167
|
ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
|
|
168
|
+
localisedUrls?: LocalisedUrlsOption,
|
|
165
169
|
) {
|
|
166
170
|
const hasRedirectedRef = useRef(false);
|
|
167
|
-
|
|
168
|
-
const { navigate, location, hasRouter } = useRouterHooks();
|
|
171
|
+
const { navigate, location, hasRouter } = useI18nRouterAdapter();
|
|
169
172
|
|
|
170
173
|
useEffect(() => {
|
|
171
174
|
if (process.env.MODERN_TARGET !== 'browser') {
|
|
@@ -220,7 +223,12 @@ export function useClientSideRedirect(
|
|
|
220
223
|
const targetLanguage =
|
|
221
224
|
i18nInstance.language || fallbackLanguage || languages[0] || 'en';
|
|
222
225
|
|
|
223
|
-
const newPath = buildLocalizedUrl(
|
|
226
|
+
const newPath = buildLocalizedUrl(
|
|
227
|
+
relativePath,
|
|
228
|
+
targetLanguage,
|
|
229
|
+
languages,
|
|
230
|
+
localisedUrls,
|
|
231
|
+
);
|
|
224
232
|
const newUrl = entryPath + newPath + currentSearch + currentHash;
|
|
225
233
|
|
|
226
234
|
if (newUrl !== currentPathname + currentSearch + currentHash) {
|
|
@@ -244,6 +252,7 @@ export function useClientSideRedirect(
|
|
|
244
252
|
languages,
|
|
245
253
|
fallbackLanguage,
|
|
246
254
|
ignoreRedirectRoutes,
|
|
255
|
+
localisedUrls,
|
|
247
256
|
]);
|
|
248
257
|
}
|
|
249
258
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const DEFAULT_I18NEXT_BACKEND_OPTIONS = {
|
|
2
|
-
loadPath: './locales/{{lng}}/{{ns}}.json',
|
|
3
|
-
addPath: './locales/{{lng}}/{{ns}}.json',
|
|
2
|
+
loadPath: './config/public/locales/{{lng}}/{{ns}}.json',
|
|
3
|
+
addPath: './config/public/locales/{{lng}}/{{ns}}.json',
|
|
4
4
|
};
|
|
5
5
|
|
|
6
6
|
function convertPath(path: string | undefined): string | undefined {
|