@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.99 → 3.4.0-ultramodern.1
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,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> };
|
|
@@ -1,6 +1,44 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import nodePath from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Conventional locales roots, in the same priority order as the CLI plugin's
|
|
6
|
+
* `detectLocalesDirectory` auto-detection (project-root `./locales` first —
|
|
7
|
+
* the upstream convention — then the scaffold's `./config/public/locales`).
|
|
8
|
+
* The fs-backend default must read from the same directory whose existence
|
|
9
|
+
* enabled the backend in the first place.
|
|
10
|
+
*/
|
|
11
|
+
const CONVENTIONAL_LOCALES_DIRS = [
|
|
12
|
+
'./locales',
|
|
13
|
+
'./config/public/locales',
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
const isDirectory = (dirPath: string): boolean => {
|
|
17
|
+
try {
|
|
18
|
+
return fs.statSync(dirPath).isDirectory();
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const resolveDefaultLocalesDir = (
|
|
25
|
+
cwd: string = process.cwd(),
|
|
26
|
+
): string => {
|
|
27
|
+
for (const dir of CONVENTIONAL_LOCALES_DIRS) {
|
|
28
|
+
if (isDirectory(nodePath.resolve(cwd, dir))) {
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return CONVENTIONAL_LOCALES_DIRS[0];
|
|
33
|
+
};
|
|
34
|
+
|
|
1
35
|
export const DEFAULT_I18NEXT_BACKEND_OPTIONS = {
|
|
2
|
-
loadPath:
|
|
3
|
-
|
|
36
|
+
get loadPath(): string {
|
|
37
|
+
return `${resolveDefaultLocalesDir()}/{{lng}}/{{ns}}.json`;
|
|
38
|
+
},
|
|
39
|
+
get addPath(): string {
|
|
40
|
+
return `${resolveDefaultLocalesDir()}/{{lng}}/{{ns}}.json`;
|
|
41
|
+
},
|
|
4
42
|
};
|
|
5
43
|
|
|
6
44
|
function convertPath(path: string | undefined): string | undefined {
|
|
@@ -14,18 +52,85 @@ function convertPath(path: string | undefined): string | undefined {
|
|
|
14
52
|
return path;
|
|
15
53
|
}
|
|
16
54
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
55
|
+
interface InternalBackendPathOptions {
|
|
56
|
+
loadPath?: string;
|
|
57
|
+
addPath?: string;
|
|
58
|
+
serverLoadPath?: string;
|
|
59
|
+
serverAddPath?: string;
|
|
60
|
+
serverLoadPaths?: string[];
|
|
61
|
+
serverAddPaths?: string[];
|
|
62
|
+
_detectedLoadPath?: string;
|
|
63
|
+
_detectedAddPath?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function shouldUseServerPath(
|
|
67
|
+
currentPath: string | undefined,
|
|
68
|
+
detectedPath: string | undefined,
|
|
69
|
+
): boolean {
|
|
70
|
+
return !detectedPath || currentPath === detectedPath;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getResourceBasePath(resourcePath: string): string {
|
|
74
|
+
const markerIndex = resourcePath.indexOf('{{lng}}');
|
|
75
|
+
if (markerIndex < 0) {
|
|
76
|
+
return resourcePath;
|
|
77
|
+
}
|
|
78
|
+
return resourcePath.slice(0, markerIndex).replace(/[\\/]+$/, '');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function pathExists(resourcePath: string): boolean {
|
|
82
|
+
try {
|
|
83
|
+
return fs.existsSync(getResourceBasePath(resourcePath));
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getServerPath(
|
|
90
|
+
pathCandidates: string[] | undefined,
|
|
91
|
+
fallbackPath: string | undefined,
|
|
92
|
+
): string | undefined {
|
|
93
|
+
const candidates = Array.from(
|
|
94
|
+
new Set([...(pathCandidates || []), fallbackPath].filter(Boolean)),
|
|
95
|
+
) as string[];
|
|
96
|
+
|
|
97
|
+
return candidates.find(pathExists) || candidates[0];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function convertBackendOptions<T extends InternalBackendPathOptions>(
|
|
101
|
+
options: T,
|
|
102
|
+
): T {
|
|
20
103
|
if (!options) {
|
|
21
104
|
return options;
|
|
22
105
|
}
|
|
23
106
|
const converted = { ...options };
|
|
24
|
-
if (
|
|
107
|
+
if (
|
|
108
|
+
(converted.serverLoadPath || converted.serverLoadPaths) &&
|
|
109
|
+
shouldUseServerPath(converted.loadPath, converted._detectedLoadPath)
|
|
110
|
+
) {
|
|
111
|
+
converted.loadPath = getServerPath(
|
|
112
|
+
converted.serverLoadPaths,
|
|
113
|
+
converted.serverLoadPath,
|
|
114
|
+
);
|
|
115
|
+
} else if (converted.loadPath) {
|
|
25
116
|
converted.loadPath = convertPath(converted.loadPath);
|
|
26
117
|
}
|
|
27
|
-
if (
|
|
118
|
+
if (
|
|
119
|
+
(converted.serverAddPath || converted.serverAddPaths) &&
|
|
120
|
+
shouldUseServerPath(converted.addPath, converted._detectedAddPath)
|
|
121
|
+
) {
|
|
122
|
+
converted.addPath = getServerPath(
|
|
123
|
+
converted.serverAddPaths,
|
|
124
|
+
converted.serverAddPath,
|
|
125
|
+
);
|
|
126
|
+
} else if (converted.addPath) {
|
|
28
127
|
converted.addPath = convertPath(converted.addPath);
|
|
29
128
|
}
|
|
129
|
+
delete converted.serverLoadPath;
|
|
130
|
+
delete converted.serverAddPath;
|
|
131
|
+
delete converted.serverLoadPaths;
|
|
132
|
+
delete converted.serverAddPaths;
|
|
133
|
+
delete converted._detectedLoadPath;
|
|
134
|
+
delete converted._detectedAddPath;
|
|
30
135
|
return converted;
|
|
31
136
|
}
|
|
@@ -3,32 +3,34 @@ export const DEFAULT_I18NEXT_BACKEND_OPTIONS = {
|
|
|
3
3
|
addPath: '/locales/{{lng}}/{{ns}}.json',
|
|
4
4
|
};
|
|
5
5
|
|
|
6
|
-
declare global {
|
|
7
|
-
interface Window {
|
|
8
|
-
__assetPrefix__?: string;
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
|
|
12
6
|
function convertPath(path: string | undefined): string | undefined {
|
|
13
|
-
if (!path) {
|
|
14
|
-
return path;
|
|
15
|
-
}
|
|
16
|
-
// If it's an absolute path (starts with /), convert to relative path
|
|
17
|
-
if (path.startsWith('/')) {
|
|
18
|
-
return typeof window === 'undefined'
|
|
19
|
-
? path
|
|
20
|
-
: `${window.__assetPrefix__ || ''}${path}`;
|
|
21
|
-
}
|
|
22
7
|
return path;
|
|
23
8
|
}
|
|
24
9
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
10
|
+
interface InternalBackendPathOptions {
|
|
11
|
+
loadPath?: string;
|
|
12
|
+
addPath?: string;
|
|
13
|
+
serverLoadPath?: string;
|
|
14
|
+
serverAddPath?: string;
|
|
15
|
+
serverLoadPaths?: string[];
|
|
16
|
+
serverAddPaths?: string[];
|
|
17
|
+
_detectedLoadPath?: string;
|
|
18
|
+
_detectedAddPath?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function convertBackendOptions<T extends InternalBackendPathOptions>(
|
|
22
|
+
options: T,
|
|
23
|
+
): T {
|
|
28
24
|
if (!options) {
|
|
29
25
|
return options;
|
|
30
26
|
}
|
|
31
27
|
const converted = { ...options };
|
|
28
|
+
delete converted.serverLoadPath;
|
|
29
|
+
delete converted.serverAddPath;
|
|
30
|
+
delete converted.serverLoadPaths;
|
|
31
|
+
delete converted.serverAddPaths;
|
|
32
|
+
delete converted._detectedLoadPath;
|
|
33
|
+
delete converted._detectedAddPath;
|
|
32
34
|
if (converted.loadPath) {
|
|
33
35
|
converted.loadPath = convertPath(converted.loadPath);
|
|
34
36
|
}
|
package/src/runtime/index.tsx
CHANGED
|
@@ -82,7 +82,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
|
|
|
82
82
|
} = localeDetection || {};
|
|
83
83
|
const { enabled: backendEnabled = false } = backend || {};
|
|
84
84
|
let latestI18nInstance: I18nInstance | undefined;
|
|
85
|
-
let I18nextProvider: React.
|
|
85
|
+
let I18nextProvider: React.ComponentType<any> | null;
|
|
86
86
|
|
|
87
87
|
const loadReactI18nextIntegration = async () => {
|
|
88
88
|
if (!reactI18next) {
|
|
@@ -293,6 +293,28 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
|
|
|
293
293
|
},
|
|
294
294
|
});
|
|
295
295
|
|
|
296
|
+
export type {
|
|
297
|
+
AllowedLinkTarget,
|
|
298
|
+
CanonicalRoutePath,
|
|
299
|
+
UltramodernCanonicalRoutes,
|
|
300
|
+
} from './canonicalRoutes';
|
|
296
301
|
export { useModernI18n } from './context';
|
|
297
|
-
export { I18nLink } from './I18nLink';
|
|
302
|
+
export { I18nLink, type I18nLinkProps } from './I18nLink';
|
|
303
|
+
export {
|
|
304
|
+
Link,
|
|
305
|
+
type LinkActiveOptions,
|
|
306
|
+
type LinkBaseProps,
|
|
307
|
+
type LinkParams,
|
|
308
|
+
type LinkProps,
|
|
309
|
+
} from './Link';
|
|
310
|
+
export {
|
|
311
|
+
canonicalPath,
|
|
312
|
+
type LocalizedPathsConfig,
|
|
313
|
+
localizePath,
|
|
314
|
+
type UseLocalizedLocationReturn,
|
|
315
|
+
type UseLocalizedPathsReturn,
|
|
316
|
+
useLocalizedLocation,
|
|
317
|
+
useLocalizedPaths,
|
|
318
|
+
} from './localizedPaths';
|
|
319
|
+
export { buildLocalizedUrl, splitUrlTarget } from './utils';
|
|
298
320
|
export default i18nPlugin;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import type { LocalisedUrlsOption } from '../shared/localisedUrls';
|
|
3
|
+
import { canonicalTargetPathname } from '../shared/localisedUrls';
|
|
4
|
+
import { useModernI18n } from './context';
|
|
5
|
+
import { useI18nRouterAdapter } from './routerAdapter';
|
|
6
|
+
import { buildLocalizedUrl, splitUrlTarget } from './utils';
|
|
7
|
+
|
|
8
|
+
export interface LocalizedPathsConfig {
|
|
9
|
+
languages: string[];
|
|
10
|
+
localisedUrls?: LocalisedUrlsOption;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Localize a canonical, language-agnostic target for the given language:
|
|
15
|
+
* adds the language prefix and applies `localisedUrls` pattern mapping.
|
|
16
|
+
* `?search`/`#hash` suffixes are preserved verbatim.
|
|
17
|
+
*/
|
|
18
|
+
export const localizePath = (
|
|
19
|
+
pathname: string,
|
|
20
|
+
language: string,
|
|
21
|
+
config: LocalizedPathsConfig,
|
|
22
|
+
): string =>
|
|
23
|
+
buildLocalizedUrl(pathname, language, config.languages, config.localisedUrls);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reverse of {@link localizePath}: strip the language prefix and map localized
|
|
27
|
+
* slugs back to the canonical pattern's path. `?search`/`#hash` suffixes are
|
|
28
|
+
* preserved verbatim.
|
|
29
|
+
*/
|
|
30
|
+
export const canonicalPath = (
|
|
31
|
+
target: string,
|
|
32
|
+
config: LocalizedPathsConfig,
|
|
33
|
+
): string => {
|
|
34
|
+
const { pathname, search, hash } = splitUrlTarget(target);
|
|
35
|
+
const resolvedPath = canonicalTargetPathname(
|
|
36
|
+
pathname,
|
|
37
|
+
config.languages,
|
|
38
|
+
config.localisedUrls,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return `${resolvedPath}${search}${hash}`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface UseLocalizedPathsReturn {
|
|
45
|
+
localizePath: (pathname: string, language: string) => string;
|
|
46
|
+
canonicalPath: (pathname: string) => string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Context-bound versions of {@link localizePath} and {@link canonicalPath} —
|
|
51
|
+
* the plugin configuration (languages, localisedUrls) is read from the i18n
|
|
52
|
+
* provider, so apps never copy pattern-matching helpers again.
|
|
53
|
+
*/
|
|
54
|
+
export const useLocalizedPaths = (): UseLocalizedPathsReturn => {
|
|
55
|
+
const { supportedLanguages, localisedUrls } = useModernI18n();
|
|
56
|
+
|
|
57
|
+
return useMemo(() => {
|
|
58
|
+
const config: LocalizedPathsConfig = {
|
|
59
|
+
languages: supportedLanguages,
|
|
60
|
+
localisedUrls,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
localizePath: (pathname: string, language: string) =>
|
|
65
|
+
localizePath(pathname, language, config),
|
|
66
|
+
canonicalPath: (pathname: string) => canonicalPath(pathname, config),
|
|
67
|
+
};
|
|
68
|
+
}, [supportedLanguages, localisedUrls]);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export interface UseLocalizedLocationReturn {
|
|
72
|
+
language: string;
|
|
73
|
+
/** Canonical (language-agnostic) path of the current location. */
|
|
74
|
+
canonical: string;
|
|
75
|
+
/** Per-language hrefs for the current location, search+hash preserved. */
|
|
76
|
+
alternates: Record<string, string>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Per-language hrefs for the current location — for hreflang `<link>` tags and
|
|
81
|
+
* language switchers. SSR-safe: the location comes from the router adapter.
|
|
82
|
+
*/
|
|
83
|
+
export const useLocalizedLocation = (): UseLocalizedLocationReturn => {
|
|
84
|
+
const { language, supportedLanguages, localisedUrls } = useModernI18n();
|
|
85
|
+
const { location } = useI18nRouterAdapter();
|
|
86
|
+
const pathname = location?.pathname ?? '/';
|
|
87
|
+
const search = location?.search ?? '';
|
|
88
|
+
const hash = location?.hash ?? '';
|
|
89
|
+
|
|
90
|
+
return useMemo(() => {
|
|
91
|
+
const config: LocalizedPathsConfig = {
|
|
92
|
+
languages: supportedLanguages,
|
|
93
|
+
localisedUrls,
|
|
94
|
+
};
|
|
95
|
+
const alternates: Record<string, string> = {};
|
|
96
|
+
for (const supportedLanguage of supportedLanguages) {
|
|
97
|
+
alternates[supportedLanguage] =
|
|
98
|
+
`${localizePath(pathname, supportedLanguage, config)}${search}${hash}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
language,
|
|
103
|
+
canonical: canonicalPath(pathname, config),
|
|
104
|
+
alternates,
|
|
105
|
+
};
|
|
106
|
+
}, [language, supportedLanguages, localisedUrls, pathname, search, hash]);
|
|
107
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isBrowser, RuntimeContext } from '@modern-js/runtime';
|
|
2
2
|
import {
|
|
3
|
+
getRouterRuntimeState,
|
|
3
4
|
InternalRuntimeContext,
|
|
4
5
|
type TInternalRuntimeContext,
|
|
5
6
|
type TRuntimeContext,
|
|
@@ -131,9 +132,8 @@ const getRouterFramework = (
|
|
|
131
132
|
inReactRouter: boolean,
|
|
132
133
|
): I18nRouterFramework | undefined => {
|
|
133
134
|
const framework =
|
|
134
|
-
internalContext
|
|
135
|
-
|
|
136
|
-
runtimeContext.routerFramework;
|
|
135
|
+
getRouterRuntimeState(internalContext)?.framework ||
|
|
136
|
+
getRouterRuntimeState(runtimeContext)?.framework;
|
|
137
137
|
|
|
138
138
|
if (framework) {
|
|
139
139
|
return framework;
|
|
@@ -167,8 +167,7 @@ const getRouterInstance = (
|
|
|
167
167
|
return contextRouter;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
const router =
|
|
171
|
-
internalContext.routerInstance || internalContext.routerRuntime?.instance;
|
|
170
|
+
const router = getRouterRuntimeState(internalContext)?.instance;
|
|
172
171
|
if (!router || typeof router !== 'object') {
|
|
173
172
|
return null;
|
|
174
173
|
}
|
package/src/runtime/types.ts
CHANGED
package/src/runtime/utils.ts
CHANGED
|
@@ -3,11 +3,8 @@ import {
|
|
|
3
3
|
getGlobalBasename,
|
|
4
4
|
type TInternalRuntimeContext,
|
|
5
5
|
} from '@modern-js/runtime/context';
|
|
6
|
-
import type {
|
|
7
|
-
import {
|
|
8
|
-
resolveLocalisedPath,
|
|
9
|
-
resolveLocalisedUrlsConfig,
|
|
10
|
-
} from '../shared/localisedUrls';
|
|
6
|
+
import type { LocalisedUrlsOption } from '../shared/localisedUrls';
|
|
7
|
+
import { localiseTargetPathname } from '../shared/localisedUrls';
|
|
11
8
|
|
|
12
9
|
export const getPathname = (context: TInternalRuntimeContext): string => {
|
|
13
10
|
if (isBrowser()) {
|
|
@@ -45,36 +42,46 @@ export const getLanguageFromPath = (
|
|
|
45
42
|
return fallbackLanguage;
|
|
46
43
|
};
|
|
47
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Split a link target into its pathname, search and hash parts without
|
|
47
|
+
* relying on `new URL` (SSR-hot path; targets are relative).
|
|
48
|
+
*/
|
|
49
|
+
export const splitUrlTarget = (
|
|
50
|
+
target: string,
|
|
51
|
+
): { pathname: string; search: string; hash: string } => {
|
|
52
|
+
const hashIndex = target.indexOf('#');
|
|
53
|
+
const hash = hashIndex >= 0 ? target.slice(hashIndex) : '';
|
|
54
|
+
const beforeHash = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
|
|
55
|
+
const searchIndex = beforeHash.indexOf('?');
|
|
56
|
+
const search = searchIndex >= 0 ? beforeHash.slice(searchIndex) : '';
|
|
57
|
+
const pathname =
|
|
58
|
+
searchIndex >= 0 ? beforeHash.slice(0, searchIndex) : beforeHash;
|
|
59
|
+
|
|
60
|
+
return { pathname, search, hash };
|
|
61
|
+
};
|
|
62
|
+
|
|
48
63
|
/**
|
|
49
64
|
* Helper function to build localized URL
|
|
50
|
-
* @param
|
|
65
|
+
* @param target - The language-agnostic target; may include `?search` and `#hash`
|
|
51
66
|
* @param language - The target language
|
|
52
67
|
* @param languages - Array of supported languages
|
|
53
|
-
* @returns The localized URL path
|
|
68
|
+
* @returns The localized URL path with search and hash re-appended verbatim
|
|
54
69
|
*/
|
|
55
70
|
export const buildLocalizedUrl = (
|
|
56
|
-
|
|
71
|
+
target: string,
|
|
57
72
|
language: string,
|
|
58
73
|
languages: string[],
|
|
59
|
-
localisedUrls?:
|
|
74
|
+
localisedUrls?: LocalisedUrlsOption,
|
|
60
75
|
): string => {
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
language,
|
|
71
|
-
languages,
|
|
72
|
-
localisedUrlsConfig.map,
|
|
73
|
-
)
|
|
74
|
-
: pathWithoutLanguage;
|
|
75
|
-
const resolvedSegments = resolvedPath.split('/').filter(Boolean);
|
|
76
|
-
|
|
77
|
-
return `/${[language, ...resolvedSegments].join('/')}`;
|
|
76
|
+
const { pathname, search, hash } = splitUrlTarget(target);
|
|
77
|
+
const localizedPathname = localiseTargetPathname(
|
|
78
|
+
pathname,
|
|
79
|
+
language,
|
|
80
|
+
languages,
|
|
81
|
+
localisedUrls,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return `${localizedPathname}${search}${hash}`;
|
|
78
85
|
};
|
|
79
86
|
|
|
80
87
|
export const detectLanguageFromPath = (
|
package/src/server/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from '../runtime/i18n/detection/config.js';
|
|
10
10
|
import type { LanguageDetectorOptions } from '../runtime/i18n/instance';
|
|
11
11
|
import {
|
|
12
|
-
|
|
12
|
+
localiseTargetPathname,
|
|
13
13
|
resolveLocalisedUrlsConfig,
|
|
14
14
|
} from '../shared/localisedUrls.js';
|
|
15
15
|
import type { LocaleDetectionOptions } from '../shared/type';
|
|
@@ -314,22 +314,12 @@ const buildLocalizedUrl = (
|
|
|
314
314
|
? pathname.slice(basePath.length)
|
|
315
315
|
: pathname;
|
|
316
316
|
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const resolvedPath = localisedUrlsConfig.enabled
|
|
324
|
-
? resolveLocalisedPath(
|
|
325
|
-
pathWithoutLanguage,
|
|
326
|
-
language,
|
|
327
|
-
languages,
|
|
328
|
-
localisedUrlsConfig.map,
|
|
329
|
-
)
|
|
330
|
-
: pathWithoutLanguage;
|
|
331
|
-
const resolvedSegments = resolvedPath.split('/').filter(Boolean);
|
|
332
|
-
const newPathname = `/${[language, ...resolvedSegments].join('/')}`;
|
|
317
|
+
const newPathname = localiseTargetPathname(
|
|
318
|
+
remainingPath,
|
|
319
|
+
language,
|
|
320
|
+
languages,
|
|
321
|
+
localisedUrls,
|
|
322
|
+
);
|
|
333
323
|
// Handle root path case to avoid double slashes like //en
|
|
334
324
|
const suffix = `${url.search}${url.hash}`;
|
|
335
325
|
const localizedUrl =
|