@bleedingdev/modern-js-plugin-i18n 3.9.0-ultramodern.7 → 3.9.0-ultramodern.9
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/dist/cjs/cli/index.js +11 -0
- package/dist/cjs/cli/localisedRoutes.js +46 -0
- package/dist/cjs/runtime/core.js +4 -1
- package/dist/cjs/runtime/utils.js +6 -2
- package/dist/cjs/server/defaultUrlStrategy.js +48 -0
- package/dist/cjs/server/index.js +3 -2
- package/dist/cjs/server/mappedUrlStrategy.js +43 -0
- package/dist/cjs/server/redirectPolicy.js +13 -9
- package/dist/cjs/shared/mappedUrlStrategy.js +63 -0
- package/dist/cjs/shared/urlStrategy.js +31 -1
- package/dist/esm/cli/index.mjs +11 -0
- package/dist/esm/cli/localisedRoutes.mjs +8 -0
- package/dist/esm/runtime/core.mjs +4 -1
- package/dist/esm/runtime/utils.mjs +6 -2
- package/dist/esm/server/defaultUrlStrategy.mjs +7 -0
- package/dist/esm/server/index.mjs +3 -2
- package/dist/esm/server/mappedUrlStrategy.mjs +1 -0
- package/dist/esm/server/redirectPolicy.mjs +6 -2
- package/dist/esm/shared/mappedUrlStrategy.mjs +19 -0
- package/dist/esm/shared/urlStrategy.mjs +7 -0
- package/dist/esm-node/cli/index.mjs +11 -0
- package/dist/esm-node/cli/localisedRoutes.mjs +9 -0
- package/dist/esm-node/runtime/core.mjs +4 -1
- package/dist/esm-node/runtime/utils.mjs +6 -2
- package/dist/esm-node/server/defaultUrlStrategy.mjs +8 -0
- package/dist/esm-node/server/index.mjs +3 -2
- package/dist/esm-node/server/mappedUrlStrategy.mjs +2 -0
- package/dist/esm-node/server/redirectPolicy.mjs +6 -2
- package/dist/esm-node/shared/mappedUrlStrategy.mjs +20 -0
- package/dist/esm-node/shared/urlStrategy.mjs +7 -0
- package/dist/types/cli/localisedRoutes.d.ts +18 -0
- package/dist/types/runtime/core.d.ts +1 -1
- package/dist/types/runtime/utils.d.ts +1 -1
- package/dist/types/server/defaultUrlStrategy.d.ts +11 -0
- package/dist/types/server/index.d.ts +1 -1
- package/dist/types/server/mappedUrlStrategy.d.ts +7 -0
- package/dist/types/server/redirectPolicy.d.ts +1 -1
- package/dist/types/shared/mappedUrlStrategy.d.ts +27 -0
- package/dist/types/shared/type.d.ts +10 -0
- package/dist/types/shared/urlStrategy.d.ts +11 -0
- package/package.json +11 -10
- package/rstest.config.mts +4 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/localisedRoutes.ts +43 -0
- package/src/runtime/core.tsx +18 -2
- package/src/runtime/utils.ts +13 -4
- package/src/server/defaultUrlStrategy.ts +27 -0
- package/src/server/index.ts +7 -12
- package/src/server/mappedUrlStrategy.ts +10 -0
- package/src/server/redirectPolicy.ts +19 -4
- package/src/shared/mappedUrlStrategy.ts +59 -0
- package/src/shared/type.ts +10 -0
- package/src/shared/urlStrategy.ts +27 -0
- package/tests/defaultUrlStrategy.test.ts +107 -0
- package/tests/localisedRouteGeneration.test.ts +159 -0
- package/tests/localisedUrls.test.ts +21 -4
- package/tests/mappedUrlStrategyDerivation.test.ts +62 -0
- package/tests/redirectPolicy.test.ts +53 -0
- package/tests/urlStrategySerialization.test.ts +122 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import "node:module";
|
|
2
|
+
import { resolveMappedUrlStrategy } from "../shared/mappedUrlStrategy.mjs";
|
|
3
|
+
import { asI18nUrlStrategy } from "../shared/urlStrategy.mjs";
|
|
2
4
|
import { setupI18nBeforeRender } from "./pluginSetup.mjs";
|
|
3
5
|
import { createI18nRootWrapper } from "./providerComposition.mjs";
|
|
4
6
|
import { resolveReactI18nextIntegration } from "./reactI18next.mjs";
|
|
@@ -9,6 +11,7 @@ const createI18nPlugin = (loadReactI18nextIntegration)=>(options)=>({
|
|
|
9
11
|
const { entryName, i18nInstance: userI18nInstance, initOptions, localeDetection, backend, htmlLangAttr = false, reactI18next = true, urlStrategy, NavigationProvider, LanguageSynchronization } = options;
|
|
10
12
|
const { localePathRedirect = false, i18nextDetector = true, languages = [], fallbackLanguage = 'en', detection, ignoreRedirectRoutes } = localeDetection || {};
|
|
11
13
|
const { enabled: backendEnabled = false } = backend || {};
|
|
14
|
+
const resolvedUrlStrategy = asI18nUrlStrategy(urlStrategy) ?? resolveMappedUrlStrategy(localeDetection);
|
|
12
15
|
let latestI18nInstance;
|
|
13
16
|
let I18nextProvider;
|
|
14
17
|
api.onBeforeRender(async (context)=>{
|
|
@@ -36,7 +39,7 @@ const createI18nPlugin = (loadReactI18nextIntegration)=>(options)=>({
|
|
|
36
39
|
languages,
|
|
37
40
|
fallbackLanguage,
|
|
38
41
|
ignoreRedirectRoutes,
|
|
39
|
-
urlStrategy,
|
|
42
|
+
urlStrategy: resolvedUrlStrategy,
|
|
40
43
|
NavigationProvider,
|
|
41
44
|
LanguageSynchronization,
|
|
42
45
|
getLatestI18nInstance: ()=>latestI18nInstance,
|
|
@@ -2,6 +2,8 @@ import "node:module";
|
|
|
2
2
|
import { isBrowser } from "@modern-js/runtime";
|
|
3
3
|
import { getGlobalBasename } from "@modern-js/runtime/context";
|
|
4
4
|
import { splitUrlTarget } from "@modern-js/runtime-utils/url";
|
|
5
|
+
import { isDefaultLocaleRedirectSkipPath } from "../shared/mappedUrlStrategy.mjs";
|
|
6
|
+
import { asI18nUrlStrategy } from "../shared/urlStrategy.mjs";
|
|
5
7
|
const getPathname = (context)=>{
|
|
6
8
|
if (isBrowser()) return window.location.pathname;
|
|
7
9
|
return context.ssrContext?.request?.pathname || '/';
|
|
@@ -19,7 +21,8 @@ const getLanguageFromPath = (pathname, languages, fallbackLanguage)=>{
|
|
|
19
21
|
};
|
|
20
22
|
const buildLocalizedUrl = (target, language, languages, urlStrategy)=>{
|
|
21
23
|
const { pathname, search, hash } = splitUrlTarget(target);
|
|
22
|
-
|
|
24
|
+
const strategy = asI18nUrlStrategy(urlStrategy);
|
|
25
|
+
if (strategy) return `${strategy.localizePathname(pathname, language, languages)}${search}${hash}`;
|
|
23
26
|
const segments = pathname.split('/').filter(Boolean);
|
|
24
27
|
if (segments[0] && languages.some((item)=>item.toLowerCase() === segments[0].toLowerCase())) segments[0] = language;
|
|
25
28
|
else segments.unshift(language);
|
|
@@ -44,7 +47,8 @@ const detectLanguageFromPath = (pathname, languages, localePathRedirect)=>{
|
|
|
44
47
|
};
|
|
45
48
|
};
|
|
46
49
|
const shouldIgnoreRedirect = (pathname, languages, ignoreRedirectRoutes, urlStrategy)=>{
|
|
47
|
-
if (
|
|
50
|
+
if (isDefaultLocaleRedirectSkipPath(pathname, languages)) return true;
|
|
51
|
+
if (asI18nUrlStrategy(urlStrategy)?.shouldSkipRedirect?.(pathname, languages)) return true;
|
|
48
52
|
if (!ignoreRedirectRoutes) return false;
|
|
49
53
|
const segments = pathname.split('/').filter(Boolean);
|
|
50
54
|
if (segments[0] && languages.some((item)=>item.toLowerCase() === segments[0].toLowerCase())) segments.shift();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import "node:module";
|
|
2
|
+
import { createMappedUrlStrategy } from "./mappedUrlStrategy.mjs";
|
|
3
|
+
const createDefaultUrlStrategy = (localisedUrls)=>createMappedUrlStrategy(localisedUrls);
|
|
4
|
+
const resolveDefaultUrlStrategy = (localisedUrls)=>{
|
|
5
|
+
if (!localisedUrls || 'object' != typeof localisedUrls || 0 === Object.keys(localisedUrls).length) return;
|
|
6
|
+
return createDefaultUrlStrategy(localisedUrls);
|
|
7
|
+
};
|
|
8
|
+
export { createDefaultUrlStrategy, resolveDefaultUrlStrategy };
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import "node:module";
|
|
2
|
+
import { asI18nUrlStrategy } from "../shared/urlStrategy.mjs";
|
|
2
3
|
import { getLocaleDetectionOptions } from "../shared/utils.mjs";
|
|
3
4
|
import { collectApiPrefixes, matchesApiPrefix } from "./apiPrefix.mjs";
|
|
5
|
+
import { resolveDefaultUrlStrategy } from "./defaultUrlStrategy.mjs";
|
|
4
6
|
import { convertToHonoLanguageDetectorOptions } from "./detectorOptions.mjs";
|
|
5
7
|
import { buildLocalizedUrl, createLocaleRedirectResponse, getLanguageFromPath, isStaticResourceRequest, shouldIgnoreRedirect } from "./redirectPolicy.mjs";
|
|
6
8
|
import * as __rspack_external__modern_js_server_core_hono_0af3baa7 from "@modern-js/server-core/hono";
|
|
@@ -27,8 +29,7 @@ const i18nServerPlugin = (options)=>({
|
|
|
27
29
|
const entryLocaleDetection = getLocaleDetectionOptions(entryName, options.localeDetection);
|
|
28
30
|
const { localePathRedirect, i18nextDetector = true, languages = [], fallbackLanguage = 'en', detection, ignoreRedirectRoutes } = entryLocaleDetection;
|
|
29
31
|
const legacyLocalisedUrls = Reflect.get(entryLocaleDetection, 'localisedUrls');
|
|
30
|
-
const urlStrategy = options.resolveUrlStrategy?.(entryName);
|
|
31
|
-
if (legacyLocalisedUrls && 'object' == typeof legacyLocalisedUrls && Object.keys(legacyLocalisedUrls).length > 0 && !urlStrategy) throw new Error('Mapped locale URLs require a URL strategy. Use the i18n integration server plugin or supply resolveUrlStrategy.');
|
|
32
|
+
const urlStrategy = asI18nUrlStrategy(options.resolveUrlStrategy?.(entryName)) ?? resolveDefaultUrlStrategy(legacyLocalisedUrls);
|
|
32
33
|
const staticRoutePrefixes = options.staticRoutePrefixes;
|
|
33
34
|
const originUrlPath = route.urlPath;
|
|
34
35
|
const urlPath = originUrlPath.endsWith('/') ? `${originUrlPath}*` : `${originUrlPath}/*`;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import "node:module";
|
|
2
|
+
import { isDefaultLocaleRedirectSkipPath } from "../shared/mappedUrlStrategy.mjs";
|
|
3
|
+
import { asI18nUrlStrategy } from "../shared/urlStrategy.mjs";
|
|
2
4
|
const stripUrlPathPrefix = (pathname, urlPath)=>{
|
|
3
5
|
const basePath = urlPath.replace('/*', '');
|
|
4
6
|
if (!basePath || '/' === basePath) return pathname;
|
|
@@ -6,7 +8,8 @@ const stripUrlPathPrefix = (pathname, urlPath)=>{
|
|
|
6
8
|
};
|
|
7
9
|
const shouldIgnoreRedirect = (pathname, urlPath, ignoreRedirectRoutes, urlStrategy, languages = [])=>{
|
|
8
10
|
const remainingPath = stripUrlPathPrefix(pathname, urlPath);
|
|
9
|
-
if (
|
|
11
|
+
if (isDefaultLocaleRedirectSkipPath(remainingPath, languages)) return true;
|
|
12
|
+
if (asI18nUrlStrategy(urlStrategy)?.shouldSkipRedirect?.(remainingPath, languages)) return true;
|
|
10
13
|
if (!ignoreRedirectRoutes) return false;
|
|
11
14
|
const segments = remainingPath.split('/').filter(Boolean);
|
|
12
15
|
if (segments[0] && languages.some((language)=>language.toLowerCase() === segments[0].toLowerCase())) segments.shift();
|
|
@@ -38,7 +41,8 @@ const buildLocalizedUrl = (req, urlPath, language, languages, urlStrategy)=>{
|
|
|
38
41
|
const segments = remainingPath.split('/').filter(Boolean);
|
|
39
42
|
if (segments[0] && languages.some((item)=>item.toLowerCase() === segments[0].toLowerCase())) segments[0] = language;
|
|
40
43
|
else segments.unshift(language);
|
|
41
|
-
const
|
|
44
|
+
const strategy = asI18nUrlStrategy(urlStrategy);
|
|
45
|
+
const pathname = strategy ? strategy.localizePathname(remainingPath, language, languages) : `/${segments.join('/')}`;
|
|
42
46
|
return `${'/' === basePath ? '' : basePath}${pathname}${url.search}${url.hash}`;
|
|
43
47
|
};
|
|
44
48
|
const createLocaleRedirectResponse = (location)=>new Response(null, {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import "node:module";
|
|
2
|
+
import { canonicalTargetPathname, isDefaultLocaleRedirectSkipPath, localiseTargetPathname } from "@modern-js/i18n-runtime-extensions";
|
|
3
|
+
const createMappedUrlStrategy = (localisedUrls)=>({
|
|
4
|
+
localizePathname: (pathname, language, languages)=>localiseTargetPathname(pathname, language, [
|
|
5
|
+
...languages
|
|
6
|
+
], localisedUrls),
|
|
7
|
+
canonicalPathname: (pathname, languages)=>canonicalTargetPathname(pathname, [
|
|
8
|
+
...languages
|
|
9
|
+
], localisedUrls),
|
|
10
|
+
shouldSkipRedirect: (pathname, languages)=>isDefaultLocaleRedirectSkipPath(pathname, [
|
|
11
|
+
...languages
|
|
12
|
+
])
|
|
13
|
+
});
|
|
14
|
+
const resolveMappedUrlStrategy = (localeDetection)=>{
|
|
15
|
+
if (!localeDetection || 'object' != typeof localeDetection) return;
|
|
16
|
+
const localisedUrls = Reflect.get(localeDetection, 'localisedUrls');
|
|
17
|
+
if (!localisedUrls || 'object' != typeof localisedUrls || 0 === Object.keys(localisedUrls).length) return;
|
|
18
|
+
return createMappedUrlStrategy(localisedUrls);
|
|
19
|
+
};
|
|
20
|
+
export { createMappedUrlStrategy, isDefaultLocaleRedirectSkipPath, resolveMappedUrlStrategy };
|
|
@@ -1 +1,8 @@
|
|
|
1
1
|
import "node:module";
|
|
2
|
+
const asI18nUrlStrategy = (candidate)=>{
|
|
3
|
+
if (!candidate || 'object' != typeof candidate) return;
|
|
4
|
+
const strategy = candidate;
|
|
5
|
+
return 'function' == typeof strategy.localizePathname && 'function' == typeof strategy.canonicalPathname ? strategy : void 0;
|
|
6
|
+
};
|
|
7
|
+
const isUnusableI18nUrlStrategy = (candidate)=>Boolean(candidate) && 'object' == typeof candidate && void 0 === asI18nUrlStrategy(candidate);
|
|
8
|
+
export { asI18nUrlStrategy, isUnusableI18nUrlStrategy };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { NestedRouteForCli, PageRoute } from '@modern-js/types';
|
|
2
|
+
import type { BaseLocaleDetectionOptions } from '../shared/type.js';
|
|
3
|
+
type FileSystemRoutes = (NestedRouteForCli | PageRoute)[];
|
|
4
|
+
/**
|
|
5
|
+
* Expand file-system routes from `localeDetection.localisedUrls`.
|
|
6
|
+
*
|
|
7
|
+
* Fork-owned: the localised-URL engine lives in
|
|
8
|
+
* `@modern-js/i18n-runtime-extensions`, which upstream-owned files must not
|
|
9
|
+
* import. `cli/index.ts` calls this through a relative import so the seam, not
|
|
10
|
+
* the edge, is what appears in the upstream-shaped file.
|
|
11
|
+
*
|
|
12
|
+
* Returns the routes unchanged unless locale-path redirects are on, languages
|
|
13
|
+
* are declared, and a non-empty map is configured — an upstream-style config
|
|
14
|
+
* with `localePathRedirect` and `languages` but no map keeps plain
|
|
15
|
+
* locale-prefix behaviour.
|
|
16
|
+
*/
|
|
17
|
+
export declare const applyLocalisedRoutes: (routes: FileSystemRoutes, localeDetection: BaseLocaleDetectionOptions | undefined) => FileSystemRoutes;
|
|
18
|
+
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { RuntimePlugin } from '@modern-js/runtime';
|
|
2
2
|
import type React from 'react';
|
|
3
3
|
import type { BaseBackendOptions, BaseLocaleDetectionOptions } from '../shared/type.js';
|
|
4
|
-
import type
|
|
4
|
+
import { type I18nUrlStrategy } from '../shared/urlStrategy.js';
|
|
5
5
|
import type { I18nInitOptions, I18nInstance } from './i18n/index.js';
|
|
6
6
|
import { type I18nLanguageSynchronizationProps } from './providerComposition.js';
|
|
7
7
|
export type { I18nLanguageSynchronizationProps } from './providerComposition.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { LocalisedUrlsOption } from '@modern-js/i18n-runtime-extensions';
|
|
2
|
+
import type { I18nUrlStrategy } from '../shared/urlStrategy.js';
|
|
3
|
+
/**
|
|
4
|
+
* Mapped locale URLs work without any extra plugin. `localeDetection.localisedUrls`
|
|
5
|
+
* alone is enough: the server derives the URL policy from the map it is given.
|
|
6
|
+
* An explicit `resolveUrlStrategy` still wins, so an integration can replace the
|
|
7
|
+
* policy wholesale.
|
|
8
|
+
*/
|
|
9
|
+
export declare const createDefaultUrlStrategy: (localisedUrls?: LocalisedUrlsOption) => I18nUrlStrategy;
|
|
10
|
+
/** A non-empty map is the only configuration that needs a derived policy. */
|
|
11
|
+
export declare const resolveDefaultUrlStrategy: (localisedUrls: unknown) => I18nUrlStrategy | undefined;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ServerPlugin } from '@modern-js/server-runtime';
|
|
2
2
|
import type { LocaleDetectionOptions } from '../shared/type.js';
|
|
3
|
-
import type
|
|
3
|
+
import { type I18nUrlStrategy } from '../shared/urlStrategy.js';
|
|
4
4
|
export { collectApiPrefixes, matchesApiPrefix } from './apiPrefix.js';
|
|
5
5
|
export interface I18nPluginOptions {
|
|
6
6
|
localeDetection: LocaleDetectionOptions;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mapped-URL policy moved to `../shared` so the client runtime can derive
|
|
3
|
+
* the same policy the server does — SSR and a hydrated `<Link>` have to agree
|
|
4
|
+
* on the mapping or client navigation targets a path the server will not serve.
|
|
5
|
+
* Re-exported here to keep the server-side import path stable.
|
|
6
|
+
*/
|
|
7
|
+
export { createMappedUrlStrategy, resolveMappedUrlStrategy, } from '../shared/mappedUrlStrategy.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type LocalisedUrlsOption } from '@modern-js/i18n-runtime-extensions';
|
|
2
|
+
import type { I18nUrlStrategy } from './urlStrategy.js';
|
|
3
|
+
/**
|
|
4
|
+
* The fork's native locale-redirect exclusions (federation artifacts, static
|
|
5
|
+
* and upload prefixes). Re-exported from this fork-owned seam so the native
|
|
6
|
+
* runtime and server redirect paths apply them without importing the
|
|
7
|
+
* extensions package themselves.
|
|
8
|
+
*/
|
|
9
|
+
export { isDefaultLocaleRedirectSkipPath } from '@modern-js/i18n-runtime-extensions';
|
|
10
|
+
/**
|
|
11
|
+
* Build the URL policy from a mapped-locale-URL configuration.
|
|
12
|
+
*
|
|
13
|
+
* `localeDetection.localisedUrls` is plain data, so unlike a `urlStrategy`
|
|
14
|
+
* object it survives the JSON boundary between `modern.config.ts` and the
|
|
15
|
+
* generated runtime registration. It is therefore the one configuration both
|
|
16
|
+
* sides can rely on for mapped URLs.
|
|
17
|
+
*
|
|
18
|
+
* This is shared rather than server-only on purpose. The server and the client
|
|
19
|
+
* have to agree on the mapping or a hydrated `<Link>` navigates to a path the
|
|
20
|
+
* server will not serve: SSR renders `/cs/obchodni-podminky` while a
|
|
21
|
+
* client-side navigation asks for `/cs/terms-of-service`. The helpers below are
|
|
22
|
+
* pure pathname functions from `@modern-js/i18n-runtime-extensions`, so the
|
|
23
|
+
* client cost is a few hundred bytes of string work and no i18n engine.
|
|
24
|
+
*/
|
|
25
|
+
export declare const createMappedUrlStrategy: (localisedUrls?: LocalisedUrlsOption) => I18nUrlStrategy;
|
|
26
|
+
/** Only a non-empty map needs a derived policy. */
|
|
27
|
+
export declare const resolveMappedUrlStrategy: (localeDetection: unknown) => I18nUrlStrategy | undefined;
|
|
@@ -6,6 +6,16 @@ export interface BaseLocaleDetectionOptions {
|
|
|
6
6
|
fallbackLanguage?: string;
|
|
7
7
|
detection?: LanguageDetectorOptions;
|
|
8
8
|
ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
|
|
9
|
+
/**
|
|
10
|
+
* Canonical-path -> per-language path map, e.g.
|
|
11
|
+
* `{ '/products': { en: '/products', cs: '/produkty' } }`.
|
|
12
|
+
*
|
|
13
|
+
* Plain data on purpose: the generated runtime registration carries plugin
|
|
14
|
+
* options as JSON, so a map survives the trip where a `urlStrategy` object
|
|
15
|
+
* would lose its methods. The CLI expands file-system routes from it, and
|
|
16
|
+
* the server derives its URL strategy from it, with no extra plugin.
|
|
17
|
+
*/
|
|
18
|
+
localisedUrls?: boolean | Record<string, Record<string, string>>;
|
|
9
19
|
}
|
|
10
20
|
export interface LocaleDetectionOptions extends BaseLocaleDetectionOptions {
|
|
11
21
|
localeDetectionByEntry?: Record<string, BaseLocaleDetectionOptions>;
|
|
@@ -5,3 +5,14 @@ export interface I18nUrlStrategy {
|
|
|
5
5
|
/** Adds exclusions; it cannot enable redirects excluded by native rules. */
|
|
6
6
|
shouldSkipRedirect?(pathname: string, languages: readonly string[]): boolean;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* A URL strategy reaches the runtime through the generated runtime
|
|
10
|
+
* registration, which carries plugin options as JSON. Functions do not survive
|
|
11
|
+
* that, so a configured strategy can arrive as an empty object — which is
|
|
12
|
+
* truthy and would make every call site throw. Anything that is not a complete
|
|
13
|
+
* strategy is treated as absent, and the caller falls back to the built-in
|
|
14
|
+
* language-prefix behaviour.
|
|
15
|
+
*/
|
|
16
|
+
export declare const asI18nUrlStrategy: (candidate: unknown) => I18nUrlStrategy | undefined;
|
|
17
|
+
/** `true` when a value was configured as a strategy but cannot be used as one. */
|
|
18
|
+
export declare const isUnusableI18nUrlStrategy: (candidate: unknown) => boolean;
|
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"modern",
|
|
18
18
|
"modern.js"
|
|
19
19
|
],
|
|
20
|
-
"version": "3.9.0-ultramodern.
|
|
20
|
+
"version": "3.9.0-ultramodern.9",
|
|
21
21
|
"engines": {
|
|
22
22
|
"node": ">=20"
|
|
23
23
|
},
|
|
@@ -122,12 +122,13 @@
|
|
|
122
122
|
}
|
|
123
123
|
},
|
|
124
124
|
"dependencies": {
|
|
125
|
-
"@modern-js/
|
|
126
|
-
"@modern-js/
|
|
127
|
-
"@modern-js/
|
|
128
|
-
"@modern-js/server-
|
|
129
|
-
"@modern-js/
|
|
130
|
-
"@modern-js/
|
|
125
|
+
"@modern-js/i18n-runtime-extensions": "npm:@bleedingdev/modern-js-i18n-runtime-extensions@3.9.0-ultramodern.9",
|
|
126
|
+
"@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.9.0-ultramodern.9",
|
|
127
|
+
"@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.9",
|
|
128
|
+
"@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.9.0-ultramodern.9",
|
|
129
|
+
"@modern-js/server-runtime": "npm:@bleedingdev/modern-js-server-runtime@3.9.0-ultramodern.9",
|
|
130
|
+
"@modern-js/types": "npm:@bleedingdev/modern-js-types@3.9.0-ultramodern.9",
|
|
131
|
+
"@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.9.0-ultramodern.9",
|
|
131
132
|
"@swc/helpers": "^0.5.23",
|
|
132
133
|
"i18next-browser-languagedetector": "^8.2.1",
|
|
133
134
|
"i18next-chained-backend": "^5.0.6",
|
|
@@ -136,7 +137,7 @@
|
|
|
136
137
|
"i18next-http-middleware": "^3.9.8"
|
|
137
138
|
},
|
|
138
139
|
"peerDependencies": {
|
|
139
|
-
"@modern-js/runtime": "3.9.0-ultramodern.
|
|
140
|
+
"@modern-js/runtime": "3.9.0-ultramodern.9",
|
|
140
141
|
"i18next": ">=25.7.4",
|
|
141
142
|
"react": "^19.2.8",
|
|
142
143
|
"react-dom": "^19.2.8",
|
|
@@ -151,8 +152,8 @@
|
|
|
151
152
|
}
|
|
152
153
|
},
|
|
153
154
|
"devDependencies": {
|
|
154
|
-
"@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.
|
|
155
|
-
"@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.9.0-ultramodern.
|
|
155
|
+
"@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.9",
|
|
156
|
+
"@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.9.0-ultramodern.9",
|
|
156
157
|
"@rslib/core": "1.0.0",
|
|
157
158
|
"@types/node": "^26.4.1",
|
|
158
159
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
package/rstest.config.mts
CHANGED
|
@@ -29,6 +29,10 @@ export default {
|
|
|
29
29
|
include: [
|
|
30
30
|
'tests/i18nUtils.test.ts',
|
|
31
31
|
'tests/localisedUrls.test.ts',
|
|
32
|
+
'tests/localisedRouteGeneration.test.ts',
|
|
33
|
+
'tests/mappedUrlStrategyDerivation.test.ts',
|
|
34
|
+
'tests/defaultUrlStrategy.test.ts',
|
|
35
|
+
'tests/urlStrategySerialization.test.ts',
|
|
32
36
|
'tests/backendDefaults.test.ts',
|
|
33
37
|
'tests/redirectPolicy.test.ts',
|
|
34
38
|
'tests/reactI18nextRuntimeBoundary.test.ts',
|
package/src/cli/index.ts
CHANGED
|
@@ -3,9 +3,12 @@ import path from 'node:path';
|
|
|
3
3
|
import type { AppTools, CliPlugin } from '@modern-js/app-tools';
|
|
4
4
|
import { getPublicDirRoutePrefixes } from '@modern-js/server-core';
|
|
5
5
|
import type { Entrypoint } from '@modern-js/types';
|
|
6
|
+
import { logger } from '@modern-js/utils';
|
|
6
7
|
import type { BackendOptions, LocaleDetectionOptions } from '../shared/type';
|
|
8
|
+
import { isUnusableI18nUrlStrategy } from '../shared/urlStrategy';
|
|
7
9
|
import { getBackendOptions, getLocaleDetectionOptions } from '../shared/utils';
|
|
8
10
|
import { applyDetectedBackendPaths, detectLocalesDirectory } from './locales';
|
|
11
|
+
import { applyLocalisedRoutes } from './localisedRoutes';
|
|
9
12
|
import '../runtime/types';
|
|
10
13
|
|
|
11
14
|
export type TransformRuntimeConfigFn = (
|
|
@@ -112,12 +115,28 @@ export const i18nPlugin = (
|
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
// Build final config with base config and transformed extended config
|
|
115
|
-
const config = {
|
|
118
|
+
const config: Record<string, unknown> = {
|
|
116
119
|
entryName: entrypoint.entryName,
|
|
117
120
|
localeDetection: localeDetectionOptions,
|
|
118
121
|
backend: backendOptions,
|
|
119
122
|
...extendedConfig,
|
|
120
123
|
};
|
|
124
|
+
|
|
125
|
+
// This config is written into the generated runtime registration as
|
|
126
|
+
// JSON, so an object carrying functions arrives at the runtime with none
|
|
127
|
+
// of them. Drop it rather than shipping a broken `{}` that every call
|
|
128
|
+
// site would then have to survive, and say what to configure instead.
|
|
129
|
+
if (isUnusableI18nUrlStrategy(config.urlStrategy)) {
|
|
130
|
+
delete config.urlStrategy;
|
|
131
|
+
logger.warn(
|
|
132
|
+
'[i18n] `urlStrategy` cannot be passed through `modern.config.ts`: ' +
|
|
133
|
+
'the generated runtime registration carries plugin options as ' +
|
|
134
|
+
'JSON, which drops its methods. Configure ' +
|
|
135
|
+
'`localeDetection.localisedUrls` instead — the runtime and the ' +
|
|
136
|
+
'server both derive the URL policy from it — or register a ' +
|
|
137
|
+
'runtime plugin that supplies the strategy directly.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
121
140
|
const { reactI18next } = extendedConfig as { reactI18next?: boolean };
|
|
122
141
|
const runtimePluginPath =
|
|
123
142
|
customPlugin?.runtime?.path ||
|
|
@@ -136,6 +155,28 @@ export const i18nPlugin = (
|
|
|
136
155
|
};
|
|
137
156
|
});
|
|
138
157
|
|
|
158
|
+
// Mapped locale URLs are a property of `localeDetection.localisedUrls`, not
|
|
159
|
+
// of any particular app-tools composition, so the route expansion lives
|
|
160
|
+
// here rather than in an opt-in fork plugin. A bare `appTools()` consumer
|
|
161
|
+
// that declares the map gets `/cs/obchodni-podminky` generated alongside
|
|
162
|
+
// `/cs/terms-of-service`; without this the localized path 404s under SSR.
|
|
163
|
+
//
|
|
164
|
+
// This is deliberately not registered from `@modern-js/i18n-integration`:
|
|
165
|
+
// that package peer-depends on `@modern-js/runtime`, and pulling it into
|
|
166
|
+
// the default app-tools path closes an Nx cycle
|
|
167
|
+
// (app-tools -> app-tools-extensions -> i18n-integration -> runtime -> app-tools).
|
|
168
|
+
// `@modern-js/i18n-runtime-extensions` is already a direct dependency here,
|
|
169
|
+
// so nothing new is added to the graph.
|
|
170
|
+
api.modifyFileSystemRoutes(({ entrypoint, routes }) => ({
|
|
171
|
+
entrypoint,
|
|
172
|
+
routes: applyLocalisedRoutes(
|
|
173
|
+
routes as any,
|
|
174
|
+
localeDetection
|
|
175
|
+
? getLocaleDetectionOptions(entrypoint.entryName, localeDetection)
|
|
176
|
+
: undefined,
|
|
177
|
+
) as typeof routes,
|
|
178
|
+
}));
|
|
179
|
+
|
|
139
180
|
api._internalServerPlugins(({ plugins }) => {
|
|
140
181
|
const { serverRoutes, metaName } = api.getAppContext();
|
|
141
182
|
const normalizedConfig = api.getNormalizedConfig();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyLocalisedUrlsToRoutes,
|
|
3
|
+
resolveLocalisedUrlsConfig,
|
|
4
|
+
} from '@modern-js/i18n-runtime-extensions';
|
|
5
|
+
import type { NestedRouteForCli, PageRoute } from '@modern-js/types';
|
|
6
|
+
import type { BaseLocaleDetectionOptions } from '../shared/type';
|
|
7
|
+
|
|
8
|
+
type FileSystemRoutes = (NestedRouteForCli | PageRoute)[];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Expand file-system routes from `localeDetection.localisedUrls`.
|
|
12
|
+
*
|
|
13
|
+
* Fork-owned: the localised-URL engine lives in
|
|
14
|
+
* `@modern-js/i18n-runtime-extensions`, which upstream-owned files must not
|
|
15
|
+
* import. `cli/index.ts` calls this through a relative import so the seam, not
|
|
16
|
+
* the edge, is what appears in the upstream-shaped file.
|
|
17
|
+
*
|
|
18
|
+
* Returns the routes unchanged unless locale-path redirects are on, languages
|
|
19
|
+
* are declared, and a non-empty map is configured — an upstream-style config
|
|
20
|
+
* with `localePathRedirect` and `languages` but no map keeps plain
|
|
21
|
+
* locale-prefix behaviour.
|
|
22
|
+
*/
|
|
23
|
+
export const applyLocalisedRoutes = (
|
|
24
|
+
routes: FileSystemRoutes,
|
|
25
|
+
localeDetection: BaseLocaleDetectionOptions | undefined,
|
|
26
|
+
): FileSystemRoutes => {
|
|
27
|
+
const {
|
|
28
|
+
localePathRedirect,
|
|
29
|
+
languages = [],
|
|
30
|
+
localisedUrls,
|
|
31
|
+
} = localeDetection ?? {};
|
|
32
|
+
const resolved = resolveLocalisedUrlsConfig(localisedUrls);
|
|
33
|
+
|
|
34
|
+
if (!localePathRedirect || !languages.length || !resolved.enabled) {
|
|
35
|
+
return routes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return applyLocalisedUrlsToRoutes(
|
|
39
|
+
routes,
|
|
40
|
+
languages,
|
|
41
|
+
resolved.map,
|
|
42
|
+
) as FileSystemRoutes;
|
|
43
|
+
};
|
package/src/runtime/core.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { RuntimePlugin } from '@modern-js/runtime';
|
|
2
2
|
import type React from 'react';
|
|
3
|
+
import { resolveMappedUrlStrategy } from '../shared/mappedUrlStrategy';
|
|
3
4
|
import type {
|
|
4
5
|
BaseBackendOptions,
|
|
5
6
|
BaseLocaleDetectionOptions,
|
|
6
7
|
} from '../shared/type';
|
|
7
|
-
import type
|
|
8
|
+
import { asI18nUrlStrategy, type I18nUrlStrategy } from '../shared/urlStrategy';
|
|
8
9
|
import type { I18nInitOptions, I18nInstance } from './i18n';
|
|
9
10
|
import {
|
|
10
11
|
type RuntimeContextWithI18n,
|
|
@@ -86,6 +87,21 @@ export const createI18nPlugin =
|
|
|
86
87
|
ignoreRedirectRoutes,
|
|
87
88
|
} = localeDetection || {};
|
|
88
89
|
const { enabled: backendEnabled = false } = backend || {};
|
|
90
|
+
// A strategy configured in `modern.config.ts` reaches here through JSON,
|
|
91
|
+
// which drops its methods. Anything unusable is treated as absent so the
|
|
92
|
+
// built-in language-prefix behaviour applies instead of throwing.
|
|
93
|
+
//
|
|
94
|
+
// Falling back to `localeDetection.localisedUrls` makes mapped locale
|
|
95
|
+
// URLs work for a bare `appTools()` consumer: the map is plain data, so
|
|
96
|
+
// it survives the JSON boundary that a `urlStrategy` object does not.
|
|
97
|
+
// The server already derives its policy from the same field, and the two
|
|
98
|
+
// must agree — otherwise SSR renders `/cs/obchodni-podminky` and the
|
|
99
|
+
// hydrated `<Link>` navigates to `/cs/terms-of-service`, which 404s.
|
|
100
|
+
// An explicit, usable `urlStrategy` (supplied by a composing runtime
|
|
101
|
+
// plugin) still wins.
|
|
102
|
+
const resolvedUrlStrategy =
|
|
103
|
+
asI18nUrlStrategy(urlStrategy) ??
|
|
104
|
+
resolveMappedUrlStrategy(localeDetection);
|
|
89
105
|
let latestI18nInstance: I18nInstance | undefined;
|
|
90
106
|
let I18nextProvider: React.ComponentType<any> | null;
|
|
91
107
|
|
|
@@ -120,7 +136,7 @@ export const createI18nPlugin =
|
|
|
120
136
|
languages,
|
|
121
137
|
fallbackLanguage,
|
|
122
138
|
ignoreRedirectRoutes,
|
|
123
|
-
urlStrategy,
|
|
139
|
+
urlStrategy: resolvedUrlStrategy,
|
|
124
140
|
NavigationProvider,
|
|
125
141
|
LanguageSynchronization,
|
|
126
142
|
getLatestI18nInstance: () => latestI18nInstance,
|
package/src/runtime/utils.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { isBrowser } from '@modern-js/runtime';
|
|
2
2
|
import { getGlobalBasename } from '@modern-js/runtime/context';
|
|
3
3
|
import { splitUrlTarget } from '@modern-js/runtime-utils/url';
|
|
4
|
-
import
|
|
4
|
+
import { isDefaultLocaleRedirectSkipPath } from '../shared/mappedUrlStrategy';
|
|
5
|
+
import { asI18nUrlStrategy, type I18nUrlStrategy } from '../shared/urlStrategy';
|
|
5
6
|
|
|
6
7
|
export { splitUrlTarget } from '@modern-js/runtime-utils/url';
|
|
7
8
|
|
|
@@ -60,8 +61,9 @@ export const buildLocalizedUrl = (
|
|
|
60
61
|
urlStrategy?: I18nUrlStrategy,
|
|
61
62
|
): string => {
|
|
62
63
|
const { pathname, search, hash } = splitUrlTarget(target);
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
const strategy = asI18nUrlStrategy(urlStrategy);
|
|
65
|
+
if (strategy) {
|
|
66
|
+
return `${strategy.localizePathname(pathname, language, languages)}${search}${hash}`;
|
|
65
67
|
}
|
|
66
68
|
const segments = pathname.split('/').filter(Boolean);
|
|
67
69
|
if (
|
|
@@ -121,7 +123,14 @@ export const shouldIgnoreRedirect = (
|
|
|
121
123
|
ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
|
|
122
124
|
urlStrategy?: I18nUrlStrategy,
|
|
123
125
|
): boolean => {
|
|
124
|
-
|
|
126
|
+
// Same native exclusions as the server: federation artifacts are never
|
|
127
|
+
// locale-redirected, with or without a configured strategy.
|
|
128
|
+
if (isDefaultLocaleRedirectSkipPath(pathname, languages)) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if (
|
|
132
|
+
asI18nUrlStrategy(urlStrategy)?.shouldSkipRedirect?.(pathname, languages)
|
|
133
|
+
) {
|
|
125
134
|
return true;
|
|
126
135
|
}
|
|
127
136
|
if (!ignoreRedirectRoutes) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { LocalisedUrlsOption } from '@modern-js/i18n-runtime-extensions';
|
|
2
|
+
import type { I18nUrlStrategy } from '../shared/urlStrategy';
|
|
3
|
+
import { createMappedUrlStrategy } from './mappedUrlStrategy';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mapped locale URLs work without any extra plugin. `localeDetection.localisedUrls`
|
|
7
|
+
* alone is enough: the server derives the URL policy from the map it is given.
|
|
8
|
+
* An explicit `resolveUrlStrategy` still wins, so an integration can replace the
|
|
9
|
+
* policy wholesale.
|
|
10
|
+
*/
|
|
11
|
+
export const createDefaultUrlStrategy = (
|
|
12
|
+
localisedUrls?: LocalisedUrlsOption,
|
|
13
|
+
): I18nUrlStrategy => createMappedUrlStrategy(localisedUrls);
|
|
14
|
+
|
|
15
|
+
/** A non-empty map is the only configuration that needs a derived policy. */
|
|
16
|
+
export const resolveDefaultUrlStrategy = (
|
|
17
|
+
localisedUrls: unknown,
|
|
18
|
+
): I18nUrlStrategy | undefined => {
|
|
19
|
+
if (
|
|
20
|
+
!localisedUrls ||
|
|
21
|
+
typeof localisedUrls !== 'object' ||
|
|
22
|
+
Object.keys(localisedUrls).length === 0
|
|
23
|
+
) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return createDefaultUrlStrategy(localisedUrls as LocalisedUrlsOption);
|
|
27
|
+
};
|
package/src/server/index.ts
CHANGED
|
@@ -4,9 +4,10 @@ const { languageDetector } = honoPkg;
|
|
|
4
4
|
|
|
5
5
|
import type { Context, Next, ServerPlugin } from '@modern-js/server-runtime';
|
|
6
6
|
import type { LocaleDetectionOptions } from '../shared/type';
|
|
7
|
-
import type
|
|
7
|
+
import { asI18nUrlStrategy, type I18nUrlStrategy } from '../shared/urlStrategy';
|
|
8
8
|
import { getLocaleDetectionOptions } from '../shared/utils.js';
|
|
9
9
|
import { collectApiPrefixes, matchesApiPrefix } from './apiPrefix.js';
|
|
10
|
+
import { resolveDefaultUrlStrategy } from './defaultUrlStrategy.js';
|
|
10
11
|
import { convertToHonoLanguageDetectorOptions } from './detectorOptions.js';
|
|
11
12
|
import {
|
|
12
13
|
buildLocalizedUrl,
|
|
@@ -70,17 +71,11 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
|
|
|
70
71
|
entryLocaleDetection,
|
|
71
72
|
'localisedUrls',
|
|
72
73
|
);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
!urlStrategy
|
|
79
|
-
) {
|
|
80
|
-
throw new Error(
|
|
81
|
-
'Mapped locale URLs require a URL strategy. Use the i18n integration server plugin or supply resolveUrlStrategy.',
|
|
82
|
-
);
|
|
83
|
-
}
|
|
74
|
+
// `localeDetection.localisedUrls` is self-sufficient: when no explicit
|
|
75
|
+
// strategy is supplied the plugin derives one from the map itself.
|
|
76
|
+
const urlStrategy =
|
|
77
|
+
asI18nUrlStrategy(options.resolveUrlStrategy?.(entryName)) ??
|
|
78
|
+
resolveDefaultUrlStrategy(legacyLocalisedUrls);
|
|
84
79
|
const staticRoutePrefixes = options.staticRoutePrefixes;
|
|
85
80
|
const originUrlPath = route.urlPath;
|
|
86
81
|
const urlPath = originUrlPath.endsWith('/')
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mapped-URL policy moved to `../shared` so the client runtime can derive
|
|
3
|
+
* the same policy the server does — SSR and a hydrated `<Link>` have to agree
|
|
4
|
+
* on the mapping or client navigation targets a path the server will not serve.
|
|
5
|
+
* Re-exported here to keep the server-side import path stable.
|
|
6
|
+
*/
|
|
7
|
+
export {
|
|
8
|
+
createMappedUrlStrategy,
|
|
9
|
+
resolveMappedUrlStrategy,
|
|
10
|
+
} from '../shared/mappedUrlStrategy';
|