@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.12 → 3.2.0-ultramodern.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +221 -11
  2. package/dist/cjs/cli/index.js +32 -5
  3. package/dist/cjs/runtime/I18nLink.js +17 -28
  4. package/dist/cjs/runtime/Link.js +264 -0
  5. package/dist/cjs/runtime/canonicalRoutes.js +18 -0
  6. package/dist/cjs/runtime/context.js +41 -10
  7. package/dist/cjs/runtime/hooks.js +17 -10
  8. package/dist/cjs/runtime/i18n/backend/config.js +9 -5
  9. package/dist/cjs/runtime/i18n/backend/defaults.js +15 -10
  10. package/dist/cjs/runtime/i18n/backend/defaults.node.js +47 -8
  11. package/dist/cjs/runtime/i18n/backend/index.js +9 -5
  12. package/dist/cjs/runtime/i18n/backend/middleware.common.js +9 -5
  13. package/dist/cjs/runtime/i18n/backend/middleware.js +9 -5
  14. package/dist/cjs/runtime/i18n/backend/middleware.node.js +13 -9
  15. package/dist/cjs/runtime/i18n/backend/sdk-backend.js +9 -5
  16. package/dist/cjs/runtime/i18n/backend/sdk-event.js +16 -11
  17. package/dist/cjs/runtime/i18n/detection/config.js +9 -5
  18. package/dist/cjs/runtime/i18n/detection/index.js +9 -5
  19. package/dist/cjs/runtime/i18n/detection/middleware.js +9 -5
  20. package/dist/cjs/runtime/i18n/detection/middleware.node.js +9 -5
  21. package/dist/cjs/runtime/i18n/index.js +9 -5
  22. package/dist/cjs/runtime/i18n/instance.js +17 -37
  23. package/dist/cjs/runtime/i18n/react-i18next.js +53 -0
  24. package/dist/cjs/runtime/i18n/utils.js +9 -17
  25. package/dist/cjs/runtime/index.js +50 -15
  26. package/dist/cjs/runtime/localizedPaths.js +102 -0
  27. package/dist/cjs/runtime/routerAdapter.js +167 -0
  28. package/dist/cjs/runtime/utils.js +80 -97
  29. package/dist/cjs/server/index.js +62 -14
  30. package/dist/cjs/shared/deepMerge.js +12 -8
  31. package/dist/cjs/shared/detection.js +9 -5
  32. package/dist/cjs/shared/localisedUrls.js +351 -0
  33. package/dist/cjs/shared/utils.js +15 -11
  34. package/dist/esm/cli/index.mjs +23 -0
  35. package/dist/esm/runtime/I18nLink.mjs +7 -22
  36. package/dist/esm/runtime/Link.mjs +221 -0
  37. package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
  38. package/dist/esm/runtime/context.mjs +34 -7
  39. package/dist/esm/runtime/hooks.mjs +9 -6
  40. package/dist/esm/runtime/i18n/backend/defaults.mjs +1 -1
  41. package/dist/esm/runtime/i18n/backend/defaults.node.mjs +24 -3
  42. package/dist/esm/runtime/i18n/backend/middleware.node.mjs +3 -3
  43. package/dist/esm/runtime/i18n/instance.mjs +1 -19
  44. package/dist/esm/runtime/i18n/react-i18next.mjs +15 -0
  45. package/dist/esm/runtime/i18n/utils.mjs +0 -12
  46. package/dist/esm/runtime/index.mjs +23 -13
  47. package/dist/esm/runtime/localizedPaths.mjs +55 -0
  48. package/dist/esm/runtime/routerAdapter.mjs +129 -0
  49. package/dist/esm/runtime/utils.mjs +19 -31
  50. package/dist/esm/server/index.mjs +46 -8
  51. package/dist/esm/shared/localisedUrls.mjs +283 -0
  52. package/dist/esm-node/cli/index.mjs +23 -0
  53. package/dist/esm-node/runtime/I18nLink.mjs +7 -22
  54. package/dist/esm-node/runtime/Link.mjs +222 -0
  55. package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
  56. package/dist/esm-node/runtime/context.mjs +34 -7
  57. package/dist/esm-node/runtime/hooks.mjs +9 -6
  58. package/dist/esm-node/runtime/i18n/backend/defaults.mjs +1 -1
  59. package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +24 -3
  60. package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +3 -3
  61. package/dist/esm-node/runtime/i18n/instance.mjs +1 -19
  62. package/dist/esm-node/runtime/i18n/react-i18next.mjs +16 -0
  63. package/dist/esm-node/runtime/i18n/utils.mjs +0 -12
  64. package/dist/esm-node/runtime/index.mjs +23 -13
  65. package/dist/esm-node/runtime/localizedPaths.mjs +56 -0
  66. package/dist/esm-node/runtime/routerAdapter.mjs +130 -0
  67. package/dist/esm-node/runtime/utils.mjs +19 -31
  68. package/dist/esm-node/server/index.mjs +46 -8
  69. package/dist/esm-node/shared/localisedUrls.mjs +284 -0
  70. package/dist/types/cli/index.d.ts +1 -0
  71. package/dist/types/runtime/I18nLink.d.ts +6 -0
  72. package/dist/types/runtime/Link.d.ts +66 -0
  73. package/dist/types/runtime/canonicalRoutes.d.ts +60 -0
  74. package/dist/types/runtime/context.d.ts +3 -0
  75. package/dist/types/runtime/hooks.d.ts +4 -2
  76. package/dist/types/runtime/i18n/backend/defaults.node.d.ts +3 -2
  77. package/dist/types/runtime/i18n/backend/middleware.node.d.ts +1 -1
  78. package/dist/types/runtime/i18n/instance.d.ts +4 -6
  79. package/dist/types/runtime/i18n/react-i18next.d.ts +7 -0
  80. package/dist/types/runtime/index.d.ts +6 -1
  81. package/dist/types/runtime/localizedPaths.d.ts +39 -0
  82. package/dist/types/runtime/routerAdapter.d.ts +26 -0
  83. package/dist/types/runtime/types.d.ts +1 -1
  84. package/dist/types/runtime/utils.d.ts +13 -9
  85. package/dist/types/server/index.d.ts +6 -0
  86. package/dist/types/shared/localisedUrls.d.ts +36 -0
  87. package/dist/types/shared/type.d.ts +14 -0
  88. package/package.json +24 -24
  89. package/rstest.config.mts +44 -0
  90. package/src/cli/index.ts +44 -1
  91. package/src/runtime/I18nLink.tsx +14 -51
  92. package/src/runtime/Link.tsx +430 -0
  93. package/src/runtime/canonicalRoutes.ts +93 -0
  94. package/src/runtime/context.tsx +45 -7
  95. package/src/runtime/hooks.ts +13 -4
  96. package/src/runtime/i18n/backend/defaults.node.ts +40 -2
  97. package/src/runtime/i18n/backend/defaults.ts +3 -1
  98. package/src/runtime/i18n/backend/middleware.node.ts +1 -1
  99. package/src/runtime/i18n/instance.ts +3 -30
  100. package/src/runtime/i18n/react-i18next.ts +25 -0
  101. package/src/runtime/i18n/utils.ts +4 -26
  102. package/src/runtime/index.tsx +47 -12
  103. package/src/runtime/localizedPaths.ts +107 -0
  104. package/src/runtime/routerAdapter.tsx +332 -0
  105. package/src/runtime/types.ts +1 -1
  106. package/src/runtime/utils.ts +33 -38
  107. package/src/server/index.ts +108 -11
  108. package/src/shared/localisedUrls.ts +623 -0
  109. package/src/shared/type.ts +14 -0
  110. package/tests/backendDefaults.test.ts +51 -0
  111. package/tests/i18nUtils.test.ts +59 -0
  112. package/tests/link.test.tsx +525 -0
  113. package/tests/linkTypes.test.ts +28 -0
  114. package/tests/localisedUrls.test.ts +536 -0
  115. package/tests/routerAdapter.test.tsx +456 -0
  116. package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
  117. package/tests/type-fixture/tsconfig.json +15 -0
  118. package/dist/esm/rslib-runtime.mjs +0 -18
  119. package/dist/esm-node/rslib-runtime.mjs +0 -19
@@ -1,73 +1,36 @@
1
- import { Link, useInRouterContext, useParams } from '@modern-js/runtime/router';
2
1
  import type React from 'react';
3
- import { useModernI18n } from './context';
4
- import { buildLocalizedUrl } from './utils';
2
+ import { Link } from './Link';
5
3
 
6
4
  export interface I18nLinkProps {
7
5
  to: string;
8
6
  children: React.ReactNode;
9
- [key: string]: any; // Allow other props to be passed through
7
+ [key: string]: any;
10
8
  }
11
9
 
10
+ let warnedDeprecation = false;
11
+
12
12
  /**
13
- * I18nLink component that automatically adds language prefix to navigation links.
14
- * This component should be used within a :lang dynamic route context.
15
- *
16
- * @example
17
- * ```tsx
18
- * // When current language is 'en' and to="/about"
19
- * // The actual link will be "/en/about"
20
- * <I18nLink to="/about">About</I18nLink>
21
- *
22
- * // When current language is 'zh' and to="/"
23
- * // The actual link will be "/zh"
24
- * <I18nLink to="/">Home</I18nLink>
25
- * ```
13
+ * @deprecated Use {@link Link} from `@modern-js/plugin-i18n/runtime` instead.
14
+ * `Link` accepts the same language-agnostic `to` values and additionally
15
+ * supports `#hash`/`?query` targets, typed canonical routes, `params`
16
+ * interpolation and language-invariant active state.
26
17
  */
27
- // Use static imports to avoid breaking router tree-shaking. Detect router context via useInRouterContext.
28
- const useRouterHooks = () => {
29
- const inRouter = useInRouterContext();
30
- return {
31
- Link: inRouter ? Link : null,
32
- params: inRouter ? useParams() : ({} as any),
33
- hasRouter: inRouter,
34
- };
35
- };
36
-
37
18
  export const I18nLink: React.FC<I18nLinkProps> = ({
38
19
  to,
39
20
  children,
40
21
  ...props
41
22
  }) => {
42
- const { Link, params, hasRouter } = useRouterHooks();
43
- const { language, supportedLanguages } = useModernI18n();
44
-
45
- // Get the current language from context (which reflects the actual current language)
46
- // URL params might be stale after language changes, so we prioritize the context language
47
- const currentLang = language;
48
-
49
- // Build the localized URL by adding language prefix
50
- const localizedTo = buildLocalizedUrl(to, currentLang, supportedLanguages);
51
-
52
- // In development mode, warn if used outside of :lang route context
53
- if (process.env.NODE_ENV === 'development' && hasRouter && !params.lang) {
23
+ if (process.env.NODE_ENV === 'development' && !warnedDeprecation) {
24
+ warnedDeprecation = true;
54
25
  console.warn(
55
- 'I18nLink is being used outside of a :lang dynamic route context. ' +
56
- 'This may cause unexpected behavior. Please ensure I18nLink is used within a route that has a :lang parameter.',
57
- );
58
- }
59
-
60
- // If router is not available, render as a regular anchor tag
61
- if (!hasRouter || !Link) {
62
- return (
63
- <a href={localizedTo} {...props}>
64
- {children}
65
- </a>
26
+ '[plugin-i18n] I18nLink is deprecated. Import { Link } from ' +
27
+ "'@modern-js/plugin-i18n/runtime' instead it accepts the same " +
28
+ 'language-agnostic `to` values.',
66
29
  );
67
30
  }
68
31
 
69
32
  return (
70
- <Link to={localizedTo} {...props}>
33
+ <Link to={to} {...props}>
71
34
  {children}
72
35
  </Link>
73
36
  );
@@ -0,0 +1,430 @@
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
+ /**
123
+ * Prefetching behavior, forwarded to the underlying router link:
124
+ * react-router gets it verbatim (Modern.js `PrefetchLink` supports it),
125
+ * TanStack receives it as its native `preload` prop (`'none'` -> `false`).
126
+ * Stripped from plain `<a>` fallbacks (external / no-router targets).
127
+ */
128
+ prefetch?: 'intent' | 'render' | 'viewport' | 'none';
129
+ /**
130
+ * Native preload value of the underlying router link. When set, it wins
131
+ * over `prefetch` on the TanStack branch.
132
+ */
133
+ preload?: unknown;
134
+ activeOptions?: LinkActiveOptions;
135
+ /** Extra anchor props applied when the link is active. */
136
+ activeProps?: AnchorRest & Record<string, unknown>;
137
+ [key: string]: unknown;
138
+ }
139
+
140
+ export type LinkProps<TTo extends string = string> = LinkBaseProps & {
141
+ to: TTo;
142
+ } & ValidateLinkTo<TTo> &
143
+ LinkParamsProp<LinkTargetPathname<TTo>>;
144
+
145
+ const normalizeSearch = (
146
+ search: string | Record<string, unknown> | undefined,
147
+ searchFromTo: string,
148
+ ): {
149
+ searchString: string;
150
+ searchObject: Record<string, string> | undefined;
151
+ } => {
152
+ if (search && typeof search === 'object') {
153
+ const entries = Object.entries(search).filter(
154
+ ([, value]) => value !== undefined && value !== null,
155
+ );
156
+ const searchObject = Object.fromEntries(
157
+ entries.map(([key, value]) => [key, String(value)]),
158
+ );
159
+ const params = new URLSearchParams(searchObject);
160
+ const serialized = params.toString();
161
+ return {
162
+ searchString: serialized ? `?${serialized}` : '',
163
+ searchObject,
164
+ };
165
+ }
166
+
167
+ const raw = typeof search === 'string' && search ? search : searchFromTo;
168
+ if (!raw) {
169
+ return { searchString: '', searchObject: undefined };
170
+ }
171
+
172
+ const searchString = raw.startsWith('?') ? raw : `?${raw}`;
173
+ const searchObject: Record<string, string> = {};
174
+ new URLSearchParams(searchString).forEach((value, key) => {
175
+ searchObject[key] = value;
176
+ });
177
+
178
+ return { searchString, searchObject };
179
+ };
180
+
181
+ const splitActiveProps = (
182
+ active: boolean,
183
+ activeProps?: LinkBaseProps['activeProps'],
184
+ ) => {
185
+ if (!active || !activeProps) {
186
+ return {};
187
+ }
188
+ return activeProps;
189
+ };
190
+
191
+ const mergeClassNames = (...values: Array<unknown>): string | undefined => {
192
+ const classNames = values.filter(
193
+ (value): value is string => typeof value === 'string' && value.length > 0,
194
+ );
195
+ return classNames.length > 0 ? classNames.join(' ') : undefined;
196
+ };
197
+
198
+ /**
199
+ * The standard UltraModern link: a vanilla link in every respect except that
200
+ * it localizes canonical, language-agnostic paths automatically.
201
+ *
202
+ * - `to` accepts canonical routes (`/talks/$slug`), optionally with `#hash`
203
+ * and `?query` suffixes; both survive localization.
204
+ * - External URLs and bare `#hash` targets render a plain `<a>`.
205
+ * - Active state is language-invariant: a canonical `to` is active when the
206
+ * current location matches any localized variant of that route.
207
+ *
208
+ * @example
209
+ * ```tsx
210
+ * <Link to="/talks/$slug" params={{ slug: talk.slug }} hash="abstract" />
211
+ * <Link to="/platform" /> // -> /cs/platforma under cs
212
+ * <Link to="/#work-with-me" /> // cross-page hash, SPA navigation
213
+ * <Link to="https://ai.bleeding.dev" /> // external -> plain <a>
214
+ * ```
215
+ */
216
+ export const Link = <TTo extends string = string>(
217
+ props: LinkProps<TTo>,
218
+ ): React.ReactElement => {
219
+ const {
220
+ to,
221
+ params,
222
+ children,
223
+ hash: hashProp,
224
+ search: searchProp,
225
+ hashScrollIntoView,
226
+ activeOptions,
227
+ activeProps,
228
+ prefetch,
229
+ preload,
230
+ ...rest
231
+ } = props as LinkBaseProps & { to: string; params?: LinkParams };
232
+
233
+ const adapter = useI18nRouterAdapter();
234
+ const { language, supportedLanguages, localisedUrls } = useModernI18n();
235
+
236
+ const config: LocalizedPathsConfig = {
237
+ languages: supportedLanguages,
238
+ localisedUrls,
239
+ };
240
+
241
+ const isExternal = EXTERNAL_TARGET_RE.test(to);
242
+ const isBareHash = to.startsWith('#');
243
+
244
+ const target = useMemo(() => {
245
+ if (isExternal || isBareHash) {
246
+ return null;
247
+ }
248
+
249
+ const {
250
+ pathname,
251
+ search: searchFromTo,
252
+ hash: hashFromTo,
253
+ } = splitUrlTarget(to);
254
+ const interpolated = interpolateRouteParams(pathname || '/', params);
255
+
256
+ const firstSegment = interpolated.split('/').filter(Boolean)[0];
257
+ if (firstSegment && supportedLanguages.includes(firstSegment)) {
258
+ warnOnce(
259
+ `lang-prefix:${to}`,
260
+ `[plugin-i18n] <Link to="${to}"> starts with a language prefix. ` +
261
+ 'Write language-agnostic canonical paths; the Link localizes them automatically.',
262
+ );
263
+ }
264
+
265
+ const localizedPathname = buildLocalizedUrl(
266
+ interpolated,
267
+ language,
268
+ supportedLanguages,
269
+ localisedUrls,
270
+ );
271
+ const hash = hashProp ?? (hashFromTo ? hashFromTo.slice(1) : '');
272
+ const { searchString, searchObject } = normalizeSearch(
273
+ searchProp,
274
+ searchFromTo,
275
+ );
276
+
277
+ return {
278
+ canonicalPathname: interpolated,
279
+ localizedPathname,
280
+ hash,
281
+ searchString,
282
+ searchObject,
283
+ href: `${localizedPathname}${searchString}${hash ? `#${hash}` : ''}`,
284
+ };
285
+ }, [
286
+ to,
287
+ params,
288
+ hashProp,
289
+ searchProp,
290
+ isExternal,
291
+ isBareHash,
292
+ language,
293
+ supportedLanguages,
294
+ localisedUrls,
295
+ ]);
296
+
297
+ const isActive = useMemo(() => {
298
+ if (!target || !adapter.location) {
299
+ return false;
300
+ }
301
+
302
+ const current = canonicalPath(adapter.location.pathname, config);
303
+ const targetCanonical = canonicalPath(target.canonicalPathname, config);
304
+ const exact = activeOptions?.exact ?? targetCanonical === '/';
305
+
306
+ if (current === targetCanonical) {
307
+ return true;
308
+ }
309
+ if (exact) {
310
+ return false;
311
+ }
312
+ return current.startsWith(
313
+ targetCanonical === '/' ? '/' : `${targetCanonical}/`,
314
+ );
315
+ }, [
316
+ target,
317
+ adapter.location,
318
+ activeOptions?.exact,
319
+ supportedLanguages,
320
+ localisedUrls,
321
+ ]);
322
+
323
+ const resolvedActiveProps = splitActiveProps(isActive, activeProps);
324
+ const activeAttributes = isActive
325
+ ? {
326
+ 'data-status': 'active',
327
+ 'aria-current': (rest['aria-current'] ??
328
+ resolvedActiveProps['aria-current'] ??
329
+ 'page') as React.AriaAttributes['aria-current'],
330
+ }
331
+ : {};
332
+
333
+ // External targets and same-page anchors are vanilla links.
334
+ if (!target) {
335
+ const { replace: _replace, ...anchorProps } = rest;
336
+
337
+ return (
338
+ <a href={to} {...anchorProps}>
339
+ {children}
340
+ </a>
341
+ );
342
+ }
343
+
344
+ const { Link: RouterLink, hasRouter, framework } = adapter;
345
+
346
+ if (!hasRouter || !RouterLink) {
347
+ const { replace: _replace, ...anchorProps } = rest;
348
+ const {
349
+ className: activeClassName,
350
+ style: activeStyle,
351
+ ...activeRest
352
+ } = resolvedActiveProps;
353
+
354
+ return (
355
+ <a
356
+ href={target.href}
357
+ {...anchorProps}
358
+ {...activeRest}
359
+ {...activeAttributes}
360
+ className={mergeClassNames(rest.className, activeClassName)}
361
+ style={{
362
+ ...(rest.style as React.CSSProperties | undefined),
363
+ ...(activeStyle as React.CSSProperties | undefined),
364
+ }}
365
+ >
366
+ {children}
367
+ </a>
368
+ );
369
+ }
370
+
371
+ const {
372
+ className: activeClassName,
373
+ style: activeStyle,
374
+ ...activeRest
375
+ } = resolvedActiveProps;
376
+ const mergedClassName = mergeClassNames(rest.className, activeClassName);
377
+ const mergedStyle = {
378
+ ...(rest.style as React.CSSProperties | undefined),
379
+ ...(activeStyle as React.CSSProperties | undefined),
380
+ };
381
+
382
+ if (framework === 'tanstack') {
383
+ // TanStack's prop is `preload`; map our react-router-flavored `prefetch`
384
+ // onto it (`'none'` -> `false`). An explicit native `preload` wins.
385
+ const tanstackPreload =
386
+ preload !== undefined
387
+ ? preload
388
+ : prefetch === undefined
389
+ ? undefined
390
+ : prefetch === 'none'
391
+ ? false
392
+ : prefetch;
393
+
394
+ // Pass hash/search natively: string-concatenated targets silently break
395
+ // TanStack navigation.
396
+ return (
397
+ <RouterLink
398
+ to={target.localizedPathname}
399
+ {...(target.searchObject ? { search: target.searchObject } : {})}
400
+ {...(target.hash ? { hash: target.hash } : {})}
401
+ {...(hashScrollIntoView === undefined ? {} : { hashScrollIntoView })}
402
+ {...(tanstackPreload === undefined ? {} : { preload: tanstackPreload })}
403
+ {...rest}
404
+ {...activeRest}
405
+ {...activeAttributes}
406
+ className={mergedClassName}
407
+ style={mergedStyle}
408
+ >
409
+ {children}
410
+ </RouterLink>
411
+ );
412
+ }
413
+
414
+ return (
415
+ <RouterLink
416
+ to={target.href}
417
+ {...(prefetch === undefined ? {} : { prefetch })}
418
+ {...(preload === undefined ? {} : { preload })}
419
+ {...rest}
420
+ {...activeRest}
421
+ {...activeAttributes}
422
+ className={mergedClassName}
423
+ style={mergedStyle}
424
+ >
425
+ {children}
426
+ </RouterLink>
427
+ );
428
+ };
429
+
430
+ 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> };