@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.99 → 3.4.0-ultramodern.0
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 +17 -64
- package/dist/cjs/cli/locales.js +132 -0
- package/dist/cjs/runtime/I18nLink.js +17 -20
- package/dist/cjs/runtime/Link.js +264 -0
- package/dist/cjs/runtime/canonicalRoutes.js +18 -0
- package/dist/cjs/runtime/context.js +9 -5
- package/dist/cjs/runtime/hooks.js +9 -5
- package/dist/cjs/runtime/i18n/backend/config.js +9 -5
- package/dist/cjs/runtime/i18n/backend/defaults.js +20 -11
- package/dist/cjs/runtime/i18n/backend/defaults.node.js +79 -10
- 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 +9 -5
- 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 -13
- package/dist/cjs/runtime/i18n/react-i18next.js +12 -8
- package/dist/cjs/runtime/i18n/utils.js +9 -5
- package/dist/cjs/runtime/index.js +32 -5
- package/dist/cjs/runtime/localizedPaths.js +102 -0
- package/dist/cjs/runtime/routerAdapter.js +11 -7
- package/dist/cjs/runtime/utils.js +31 -17
- package/dist/cjs/server/index.js +10 -14
- package/dist/cjs/shared/deepMerge.js +12 -8
- package/dist/cjs/shared/detection.js +9 -5
- package/dist/cjs/shared/localisedUrls.js +148 -34
- package/dist/cjs/shared/utils.js +15 -11
- package/dist/esm/cli/index.mjs +8 -48
- package/dist/esm/cli/locales.mjs +80 -0
- package/dist/esm/runtime/I18nLink.mjs +7 -14
- package/dist/esm/runtime/Link.mjs +221 -0
- package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
- package/dist/esm/runtime/i18n/backend/defaults.mjs +6 -2
- package/dist/esm/runtime/i18n/backend/defaults.node.mjs +56 -5
- package/dist/esm/runtime/index.mjs +4 -2
- package/dist/esm/runtime/localizedPaths.mjs +55 -0
- package/dist/esm/runtime/routerAdapter.mjs +3 -3
- package/dist/esm/runtime/utils.mjs +19 -12
- package/dist/esm/server/index.mjs +2 -10
- package/dist/esm/shared/localisedUrls.mjs +115 -23
- package/dist/esm-node/cli/index.mjs +8 -48
- package/dist/esm-node/cli/locales.mjs +81 -0
- package/dist/esm-node/runtime/I18nLink.mjs +7 -14
- package/dist/esm-node/runtime/Link.mjs +222 -0
- package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
- package/dist/esm-node/runtime/i18n/backend/defaults.mjs +6 -2
- package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +56 -5
- package/dist/esm-node/runtime/index.mjs +4 -2
- package/dist/esm-node/runtime/localizedPaths.mjs +56 -0
- package/dist/esm-node/runtime/routerAdapter.mjs +3 -3
- package/dist/esm-node/runtime/utils.mjs +19 -12
- package/dist/esm-node/server/index.mjs +2 -10
- package/dist/esm-node/shared/localisedUrls.mjs +115 -23
- package/dist/types/cli/index.d.ts +1 -0
- package/dist/types/cli/locales.d.ts +17 -0
- package/dist/types/runtime/I18nLink.d.ts +4 -13
- package/dist/types/runtime/Link.d.ts +66 -0
- package/dist/types/runtime/canonicalRoutes.d.ts +60 -0
- package/dist/types/runtime/i18n/backend/defaults.d.ts +10 -7
- package/dist/types/runtime/i18n/backend/defaults.node.d.ts +13 -4
- package/dist/types/runtime/index.d.ts +5 -1
- package/dist/types/runtime/localizedPaths.d.ts +39 -0
- package/dist/types/runtime/types.d.ts +1 -1
- package/dist/types/runtime/utils.d.ts +13 -4
- package/dist/types/shared/localisedUrls.d.ts +23 -0
- package/dist/types/shared/type.d.ts +27 -5
- package/package.json +28 -25
- package/rstest.config.mts +7 -2
- package/src/cli/index.ts +25 -98
- package/src/cli/locales.ts +186 -0
- package/src/runtime/I18nLink.tsx +13 -44
- package/src/runtime/Link.tsx +430 -0
- package/src/runtime/canonicalRoutes.ts +93 -0
- package/src/runtime/i18n/backend/defaults.node.ts +112 -7
- package/src/runtime/i18n/backend/defaults.ts +20 -18
- package/src/runtime/index.tsx +24 -2
- package/src/runtime/localizedPaths.ts +107 -0
- package/src/runtime/routerAdapter.tsx +4 -5
- package/src/runtime/types.ts +1 -1
- package/src/runtime/utils.ts +33 -26
- package/src/server/index.ts +7 -17
- package/src/shared/localisedUrls.ts +256 -26
- package/src/shared/type.ts +27 -5
- package/tests/backendDefaults.test.ts +51 -0
- package/tests/i18nUtils.test.ts +10 -3
- package/tests/link.test.tsx +525 -0
- package/tests/linkTypes.test.ts +28 -0
- package/tests/localisedUrls.test.ts +224 -0
- package/tests/routerAdapter.test.tsx +86 -12
- package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
- package/tests/type-fixture/tsconfig.json +15 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getPublicDirRoutePrefixes,
|
|
3
|
+
normalizePublicDir,
|
|
4
|
+
normalizePublicDirPath,
|
|
5
|
+
} from '@modern-js/server-core';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import type { BackendOptions } from '../shared/type';
|
|
9
|
+
|
|
10
|
+
interface NormalizedConfigForLocales {
|
|
11
|
+
server?: {
|
|
12
|
+
publicDir?: string | string[];
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DetectedLocalesDirectory {
|
|
17
|
+
loadPath: string;
|
|
18
|
+
addPath: string;
|
|
19
|
+
serverLoadPath: string;
|
|
20
|
+
serverAddPath: string;
|
|
21
|
+
serverLoadPaths?: string[];
|
|
22
|
+
serverAddPaths?: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const LOCALES_RESOURCE_PATTERN = '{{lng}}/{{ns}}.json';
|
|
26
|
+
|
|
27
|
+
function hasJsonFiles(dirPath: string): boolean {
|
|
28
|
+
try {
|
|
29
|
+
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const entries = fs.readdirSync(dirPath);
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const entryPath = path.join(dirPath, entry);
|
|
35
|
+
const stat = fs.statSync(entryPath);
|
|
36
|
+
if (stat.isFile() && entry.endsWith('.json')) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
if (stat.isDirectory() && hasJsonFiles(entryPath)) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toPosixPath(filePath: string): string {
|
|
50
|
+
return filePath.split(path.sep).join(path.posix.sep);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getPublicDirOutputPath(publicDir: string): string {
|
|
54
|
+
return normalizePublicDirPath(publicDir);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildDetectedLocalesDirectory(
|
|
58
|
+
clientBasePath: string,
|
|
59
|
+
serverBasePath: string,
|
|
60
|
+
serverBasePathCandidates: string[] = [serverBasePath],
|
|
61
|
+
): DetectedLocalesDirectory {
|
|
62
|
+
const normalizedClientBasePath = clientBasePath.startsWith('/')
|
|
63
|
+
? clientBasePath
|
|
64
|
+
: `/${clientBasePath}`;
|
|
65
|
+
const normalizedServerBasePath = toPosixPath(serverBasePath).replace(
|
|
66
|
+
/\/$/,
|
|
67
|
+
'',
|
|
68
|
+
);
|
|
69
|
+
const normalizedServerBasePathCandidates = Array.from(
|
|
70
|
+
new Set(
|
|
71
|
+
serverBasePathCandidates.map(candidate =>
|
|
72
|
+
toPosixPath(candidate).replace(/\/$/, ''),
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
const serverLoadPaths = normalizedServerBasePathCandidates.map(
|
|
77
|
+
candidate => `${candidate}/${LOCALES_RESOURCE_PATTERN}`,
|
|
78
|
+
);
|
|
79
|
+
const serverAddPaths = normalizedServerBasePathCandidates.map(
|
|
80
|
+
candidate => `${candidate}/${LOCALES_RESOURCE_PATTERN}`,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
loadPath: `${normalizedClientBasePath}/${LOCALES_RESOURCE_PATTERN}`,
|
|
85
|
+
addPath: `${normalizedClientBasePath}/${LOCALES_RESOURCE_PATTERN}`,
|
|
86
|
+
serverLoadPath: `${normalizedServerBasePath}/${LOCALES_RESOURCE_PATTERN}`,
|
|
87
|
+
serverAddPath: `${normalizedServerBasePath}/${LOCALES_RESOURCE_PATTERN}`,
|
|
88
|
+
serverLoadPaths,
|
|
89
|
+
serverAddPaths,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function detectLocalesDirectory(
|
|
94
|
+
appDirectory: string,
|
|
95
|
+
normalizedConfig?: NormalizedConfigForLocales,
|
|
96
|
+
): DetectedLocalesDirectory | undefined {
|
|
97
|
+
const publicDirs = normalizePublicDir(normalizedConfig?.server?.publicDir);
|
|
98
|
+
const publicDirPrefixes = getPublicDirRoutePrefixes(
|
|
99
|
+
normalizedConfig?.server?.publicDir,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
for (let index = 0; index < publicDirs.length; index++) {
|
|
103
|
+
const publicDir = publicDirs[index];
|
|
104
|
+
if (path.isAbsolute(publicDir)) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const publicDirPath = path.join(appDirectory, publicDir);
|
|
109
|
+
const publicDirPrefix =
|
|
110
|
+
publicDirPrefixes[index] || `/${normalizePublicDirPath(publicDir)}`;
|
|
111
|
+
const publicDirOutputPath = getPublicDirOutputPath(publicDir);
|
|
112
|
+
|
|
113
|
+
if (
|
|
114
|
+
path.basename(normalizePublicDirPath(publicDir)) === 'locales' &&
|
|
115
|
+
hasJsonFiles(publicDirPath)
|
|
116
|
+
) {
|
|
117
|
+
return buildDetectedLocalesDirectory(
|
|
118
|
+
publicDirPrefix,
|
|
119
|
+
`./${publicDirOutputPath}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const localesPath = path.join(publicDirPath, 'locales');
|
|
124
|
+
if (hasJsonFiles(localesPath)) {
|
|
125
|
+
return buildDetectedLocalesDirectory(
|
|
126
|
+
`${publicDirPrefix}/locales`,
|
|
127
|
+
`./${publicDirOutputPath}/locales`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const configPublicPath = path.join(
|
|
133
|
+
appDirectory,
|
|
134
|
+
'config',
|
|
135
|
+
'public',
|
|
136
|
+
'locales',
|
|
137
|
+
);
|
|
138
|
+
if (hasJsonFiles(configPublicPath)) {
|
|
139
|
+
return buildDetectedLocalesDirectory('/locales', './public/locales', [
|
|
140
|
+
'./config/public/locales',
|
|
141
|
+
'./public/locales',
|
|
142
|
+
]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const rootLocalesPath = path.join(appDirectory, 'locales');
|
|
146
|
+
if (hasJsonFiles(rootLocalesPath)) {
|
|
147
|
+
return buildDetectedLocalesDirectory('/locales', './locales');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function applyDetectedBackendPaths(
|
|
154
|
+
backendOptions: BackendOptions,
|
|
155
|
+
detectedLocales: DetectedLocalesDirectory | undefined,
|
|
156
|
+
): BackendOptions {
|
|
157
|
+
if (!detectedLocales) {
|
|
158
|
+
return backendOptions;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const backendWithDetectedPaths: BackendOptions & {
|
|
162
|
+
_detectedLoadPath: string;
|
|
163
|
+
_detectedAddPath: string;
|
|
164
|
+
} = {
|
|
165
|
+
...backendOptions,
|
|
166
|
+
enabled: true,
|
|
167
|
+
loadPath: backendOptions.loadPath ?? detectedLocales.loadPath,
|
|
168
|
+
addPath: backendOptions.addPath ?? detectedLocales.addPath,
|
|
169
|
+
serverLoadPath:
|
|
170
|
+
backendOptions.serverLoadPath ?? detectedLocales.serverLoadPath,
|
|
171
|
+
serverLoadPaths:
|
|
172
|
+
backendOptions.serverLoadPath || backendOptions.serverLoadPaths
|
|
173
|
+
? backendOptions.serverLoadPaths
|
|
174
|
+
: detectedLocales.serverLoadPaths,
|
|
175
|
+
serverAddPath:
|
|
176
|
+
backendOptions.serverAddPath ?? detectedLocales.serverAddPath,
|
|
177
|
+
serverAddPaths:
|
|
178
|
+
backendOptions.serverAddPath || backendOptions.serverAddPaths
|
|
179
|
+
? backendOptions.serverAddPaths
|
|
180
|
+
: detectedLocales.serverAddPaths,
|
|
181
|
+
_detectedLoadPath: detectedLocales.loadPath,
|
|
182
|
+
_detectedAddPath: detectedLocales.addPath,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
return backendWithDetectedPaths;
|
|
186
|
+
}
|
package/src/runtime/I18nLink.tsx
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
|
-
import {
|
|
3
|
-
import { useI18nRouterAdapter } from './routerAdapter';
|
|
4
|
-
import { buildLocalizedUrl } from './utils';
|
|
2
|
+
import { Link } from './Link';
|
|
5
3
|
|
|
6
4
|
export interface I18nLinkProps {
|
|
7
5
|
to: string;
|
|
@@ -9,59 +7,30 @@ export interface I18nLinkProps {
|
|
|
9
7
|
[key: string]: any;
|
|
10
8
|
}
|
|
11
9
|
|
|
10
|
+
let warnedDeprecation = false;
|
|
11
|
+
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
18
|
export const I18nLink: React.FC<I18nLinkProps> = ({
|
|
28
19
|
to,
|
|
29
20
|
children,
|
|
30
21
|
...props
|
|
31
22
|
}) => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// Get the current language from context (which reflects the actual current language)
|
|
36
|
-
// URL params might be stale after language changes, so we prioritize the context language
|
|
37
|
-
const currentLang = language;
|
|
38
|
-
|
|
39
|
-
// Build the localized URL by adding language prefix
|
|
40
|
-
const localizedTo = buildLocalizedUrl(
|
|
41
|
-
to,
|
|
42
|
-
currentLang,
|
|
43
|
-
supportedLanguages,
|
|
44
|
-
localisedUrls,
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
// In development mode, warn if used outside of :lang route context
|
|
48
|
-
if (process.env.NODE_ENV === 'development' && hasRouter && !params.lang) {
|
|
23
|
+
if (process.env.NODE_ENV === 'development' && !warnedDeprecation) {
|
|
24
|
+
warnedDeprecation = true;
|
|
49
25
|
console.warn(
|
|
50
|
-
'I18nLink is
|
|
51
|
-
'
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (!hasRouter || !Link) {
|
|
56
|
-
return (
|
|
57
|
-
<a href={localizedTo} {...props}>
|
|
58
|
-
{children}
|
|
59
|
-
</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.',
|
|
60
29
|
);
|
|
61
30
|
}
|
|
62
31
|
|
|
63
32
|
return (
|
|
64
|
-
<Link to={
|
|
33
|
+
<Link to={to} {...props}>
|
|
65
34
|
{children}
|
|
66
35
|
</Link>
|
|
67
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;
|