@bleedingdev/modern-js-i18n-runtime-extensions 3.9.0-ultramodern.4 → 3.9.0-ultramodern.5

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/index.js CHANGED
@@ -6,6 +6,9 @@ var __webpack_modules__ = {
6
6
  "./language-sync/react" (module) {
7
7
  module.exports = require("./language-sync/react.js");
8
8
  },
9
+ "./localeRedirectPolicy" (module) {
10
+ module.exports = require("./localeRedirectPolicy.js");
11
+ },
9
12
  "./localisedUrls/index" (module) {
10
13
  module.exports = require("./localisedUrls/index.js");
11
14
  }
@@ -65,9 +68,13 @@ var __webpack_exports__ = {};
65
68
  var __rspack_reexport = {};
66
69
  for(const __rspack_import_key in _language_sync_react__rspack_import_1)if ("default" !== __rspack_import_key) __rspack_reexport[__rspack_import_key] = ()=>_language_sync_react__rspack_import_1[__rspack_import_key];
67
70
  __webpack_require__.d(__webpack_exports__, __rspack_reexport);
68
- var _localisedUrls_index__rspack_import_2 = __webpack_require__("./localisedUrls/index");
71
+ var _localeRedirectPolicy__rspack_import_2 = __webpack_require__("./localeRedirectPolicy");
72
+ var __rspack_reexport = {};
73
+ for(const __rspack_import_key in _localeRedirectPolicy__rspack_import_2)if ("default" !== __rspack_import_key) __rspack_reexport[__rspack_import_key] = ()=>_localeRedirectPolicy__rspack_import_2[__rspack_import_key];
74
+ __webpack_require__.d(__webpack_exports__, __rspack_reexport);
75
+ var _localisedUrls_index__rspack_import_3 = __webpack_require__("./localisedUrls/index");
69
76
  var __rspack_reexport = {};
70
- for(const __rspack_import_key in _localisedUrls_index__rspack_import_2)if ("default" !== __rspack_import_key) __rspack_reexport[__rspack_import_key] = ()=>_localisedUrls_index__rspack_import_2[__rspack_import_key];
77
+ for(const __rspack_import_key in _localisedUrls_index__rspack_import_3)if ("default" !== __rspack_import_key) __rspack_reexport[__rspack_import_key] = ()=>_localisedUrls_index__rspack_import_3[__rspack_import_key];
71
78
  __webpack_require__.d(__webpack_exports__, __rspack_reexport);
72
79
  })();
73
80
  for(var __rspack_i in __webpack_exports__)exports[__rspack_i] = __webpack_exports__[__rspack_i];
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ var __webpack_require__ = {};
3
+ (()=>{
4
+ __webpack_require__.d = (exports1, getters, values)=>{
5
+ var define = (defs, kind)=>{
6
+ for(var key in defs)if (__webpack_require__.o(defs, key) && !__webpack_require__.o(exports1, key)) Object.defineProperty(exports1, key, {
7
+ enumerable: true,
8
+ [kind]: defs[key]
9
+ });
10
+ };
11
+ define(getters, "get");
12
+ define(values, "value");
13
+ };
14
+ })();
15
+ (()=>{
16
+ __webpack_require__.o = (obj, prop)=>Object.prototype.hasOwnProperty.call(obj, prop);
17
+ })();
18
+ (()=>{
19
+ __webpack_require__.r = (exports1)=>{
20
+ if ("u" > typeof Symbol && Symbol.toStringTag) Object.defineProperty(exports1, Symbol.toStringTag, {
21
+ value: 'Module'
22
+ });
23
+ Object.defineProperty(exports1, '__esModule', {
24
+ value: true
25
+ });
26
+ };
27
+ })();
28
+ var __webpack_exports__ = {};
29
+ __webpack_require__.r(__webpack_exports__);
30
+ __webpack_require__.d(__webpack_exports__, {
31
+ buildLocalizedUrl: ()=>buildLocalizedUrl,
32
+ createLocaleRedirectResponse: ()=>createLocaleRedirectResponse,
33
+ getLanguageFromPath: ()=>getLanguageFromPath,
34
+ isStaticResourceRequest: ()=>isStaticResourceRequest,
35
+ shouldIgnoreRedirect: ()=>shouldIgnoreRedirect
36
+ });
37
+ const index_js_namespaceObject = require("./localisedUrls/index.js");
38
+ const stripUrlPathPrefix = (pathname, urlPath)=>{
39
+ const basePath = urlPath.replace('/*', '');
40
+ if (!basePath || '/' === basePath) return pathname;
41
+ const remainingPath = pathname.startsWith(basePath) ? pathname.slice(basePath.length) : pathname;
42
+ return remainingPath || '/';
43
+ };
44
+ const shouldIgnoreRedirect = (pathname, urlPath, ignoreRedirectRoutes)=>{
45
+ const remainingPath = stripUrlPathPrefix(pathname, urlPath);
46
+ return (0, index_js_namespaceObject.shouldSkipLocaleRedirect)(remainingPath, [], ignoreRedirectRoutes);
47
+ };
48
+ const isStaticResourceRequest = (pathname, staticRoutePrefixes, languages = [])=>{
49
+ if ((0, index_js_namespaceObject.isDefaultLocaleRedirectSkipPath)(pathname, languages)) return true;
50
+ const matchesStaticRoutePrefix = (targetPathname)=>staticRoutePrefixes.some((prefix)=>(0, index_js_namespaceObject.matchesPathPrefix)(targetPathname, prefix));
51
+ if (matchesStaticRoutePrefix(pathname)) return true;
52
+ const pathWithoutLanguage = (0, index_js_namespaceObject.stripLanguagePrefix)(pathname, languages);
53
+ return pathWithoutLanguage !== pathname && matchesStaticRoutePrefix(pathWithoutLanguage);
54
+ };
55
+ const getLanguageFromPath = (req, urlPath, languages)=>{
56
+ const url = new URL(req.url, `http://${req.header().host}`);
57
+ const pathname = url.pathname;
58
+ const remainingPath = stripUrlPathPrefix(pathname, urlPath);
59
+ const segments = remainingPath.split('/').filter(Boolean);
60
+ const firstSegment = segments[0];
61
+ if (languages.includes(firstSegment)) return firstSegment;
62
+ return null;
63
+ };
64
+ const buildLocalizedUrl = (req, urlPath, language, languages, localisedUrls)=>{
65
+ const url = new URL(req.url);
66
+ const pathname = url.pathname;
67
+ const basePath = urlPath.replace('/*', '');
68
+ const remainingPath = stripUrlPathPrefix(pathname, urlPath);
69
+ const newPathname = (0, index_js_namespaceObject.localiseTargetPathname)(remainingPath, language, languages, localisedUrls);
70
+ const suffix = `${url.search}${url.hash}`;
71
+ const localizedUrl = '/' === basePath ? newPathname + suffix : basePath + newPathname + suffix;
72
+ return localizedUrl;
73
+ };
74
+ const createLocaleRedirectResponse = (location)=>new Response(null, {
75
+ status: 302,
76
+ headers: {
77
+ 'Cache-Control': 'private, no-store',
78
+ Location: location,
79
+ Vary: 'Accept-Language, Cookie'
80
+ }
81
+ });
82
+ exports.buildLocalizedUrl = __webpack_exports__.buildLocalizedUrl;
83
+ exports.createLocaleRedirectResponse = __webpack_exports__.createLocaleRedirectResponse;
84
+ exports.getLanguageFromPath = __webpack_exports__.getLanguageFromPath;
85
+ exports.isStaticResourceRequest = __webpack_exports__.isStaticResourceRequest;
86
+ exports.shouldIgnoreRedirect = __webpack_exports__.shouldIgnoreRedirect;
87
+ for(var __rspack_i in __webpack_exports__)if (-1 === [
88
+ "buildLocalizedUrl",
89
+ "createLocaleRedirectResponse",
90
+ "getLanguageFromPath",
91
+ "isStaticResourceRequest",
92
+ "shouldIgnoreRedirect"
93
+ ].indexOf(__rspack_i)) exports[__rspack_i] = __webpack_exports__[__rspack_i];
94
+ Object.defineProperty(exports, '__esModule', {
95
+ value: true
96
+ });
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ var __webpack_require__ = {};
3
+ (()=>{
4
+ __webpack_require__.d = (exports1, getters, values)=>{
5
+ var define = (defs, kind)=>{
6
+ for(var key in defs)if (__webpack_require__.o(defs, key) && !__webpack_require__.o(exports1, key)) Object.defineProperty(exports1, key, {
7
+ enumerable: true,
8
+ [kind]: defs[key]
9
+ });
10
+ };
11
+ define(getters, "get");
12
+ define(values, "value");
13
+ };
14
+ })();
15
+ (()=>{
16
+ __webpack_require__.o = (obj, prop)=>Object.prototype.hasOwnProperty.call(obj, prop);
17
+ })();
18
+ (()=>{
19
+ __webpack_require__.r = (exports1)=>{
20
+ if ("u" > typeof Symbol && Symbol.toStringTag) Object.defineProperty(exports1, Symbol.toStringTag, {
21
+ value: 'Module'
22
+ });
23
+ Object.defineProperty(exports1, '__esModule', {
24
+ value: true
25
+ });
26
+ };
27
+ })();
28
+ var __webpack_exports__ = {};
29
+ __webpack_require__.r(__webpack_exports__);
30
+ __webpack_require__.d(__webpack_exports__, {
31
+ buildLocalizedUrl: ()=>buildLocalizedUrl,
32
+ canonicalPath: ()=>canonicalPath,
33
+ localizePath: ()=>localizePath
34
+ });
35
+ const url_namespaceObject = require("@modern-js/runtime-utils/url");
36
+ const index_js_namespaceObject = require("./localisedUrls/index.js");
37
+ const buildLocalizedUrl = (target, language, languages, localisedUrls)=>{
38
+ const { pathname, search, hash } = (0, url_namespaceObject.splitUrlTarget)(target);
39
+ return `${(0, index_js_namespaceObject.localiseTargetPathname)(pathname, language, languages, localisedUrls)}${search}${hash}`;
40
+ };
41
+ const localizePath = (target, language, config)=>buildLocalizedUrl(target, language, config.languages, config.localisedUrls);
42
+ const canonicalPath = (target, config)=>{
43
+ const { pathname, search, hash } = (0, url_namespaceObject.splitUrlTarget)(target);
44
+ return `${(0, index_js_namespaceObject.canonicalTargetPathname)(pathname, config.languages, config.localisedUrls)}${search}${hash}`;
45
+ };
46
+ exports.buildLocalizedUrl = __webpack_exports__.buildLocalizedUrl;
47
+ exports.canonicalPath = __webpack_exports__.canonicalPath;
48
+ exports.localizePath = __webpack_exports__.localizePath;
49
+ for(var __rspack_i in __webpack_exports__)if (-1 === [
50
+ "buildLocalizedUrl",
51
+ "canonicalPath",
52
+ "localizePath"
53
+ ].indexOf(__rspack_i)) exports[__rspack_i] = __webpack_exports__[__rspack_i];
54
+ Object.defineProperty(exports, '__esModule', {
55
+ value: true
56
+ });
@@ -1,3 +1,4 @@
1
1
  export * from "./language-sync/controller.mjs";
2
2
  export * from "./language-sync/react.mjs";
3
+ export * from "./localeRedirectPolicy.mjs";
3
4
  export * from "./localisedUrls/index.mjs";
@@ -0,0 +1,46 @@
1
+ import { isDefaultLocaleRedirectSkipPath, localiseTargetPathname, matchesPathPrefix, shouldSkipLocaleRedirect, stripLanguagePrefix } from "./localisedUrls/index.mjs";
2
+ const stripUrlPathPrefix = (pathname, urlPath)=>{
3
+ const basePath = urlPath.replace('/*', '');
4
+ if (!basePath || '/' === basePath) return pathname;
5
+ const remainingPath = pathname.startsWith(basePath) ? pathname.slice(basePath.length) : pathname;
6
+ return remainingPath || '/';
7
+ };
8
+ const shouldIgnoreRedirect = (pathname, urlPath, ignoreRedirectRoutes)=>{
9
+ const remainingPath = stripUrlPathPrefix(pathname, urlPath);
10
+ return shouldSkipLocaleRedirect(remainingPath, [], ignoreRedirectRoutes);
11
+ };
12
+ const isStaticResourceRequest = (pathname, staticRoutePrefixes, languages = [])=>{
13
+ if (isDefaultLocaleRedirectSkipPath(pathname, languages)) return true;
14
+ const matchesStaticRoutePrefix = (targetPathname)=>staticRoutePrefixes.some((prefix)=>matchesPathPrefix(targetPathname, prefix));
15
+ if (matchesStaticRoutePrefix(pathname)) return true;
16
+ const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
17
+ return pathWithoutLanguage !== pathname && matchesStaticRoutePrefix(pathWithoutLanguage);
18
+ };
19
+ const getLanguageFromPath = (req, urlPath, languages)=>{
20
+ const url = new URL(req.url, `http://${req.header().host}`);
21
+ const pathname = url.pathname;
22
+ const remainingPath = stripUrlPathPrefix(pathname, urlPath);
23
+ const segments = remainingPath.split('/').filter(Boolean);
24
+ const firstSegment = segments[0];
25
+ if (languages.includes(firstSegment)) return firstSegment;
26
+ return null;
27
+ };
28
+ const buildLocalizedUrl = (req, urlPath, language, languages, localisedUrls)=>{
29
+ const url = new URL(req.url);
30
+ const pathname = url.pathname;
31
+ const basePath = urlPath.replace('/*', '');
32
+ const remainingPath = stripUrlPathPrefix(pathname, urlPath);
33
+ const newPathname = localiseTargetPathname(remainingPath, language, languages, localisedUrls);
34
+ const suffix = `${url.search}${url.hash}`;
35
+ const localizedUrl = '/' === basePath ? newPathname + suffix : basePath + newPathname + suffix;
36
+ return localizedUrl;
37
+ };
38
+ const createLocaleRedirectResponse = (location)=>new Response(null, {
39
+ status: 302,
40
+ headers: {
41
+ 'Cache-Control': 'private, no-store',
42
+ Location: location,
43
+ Vary: 'Accept-Language, Cookie'
44
+ }
45
+ });
46
+ export { buildLocalizedUrl, createLocaleRedirectResponse, getLanguageFromPath, isStaticResourceRequest, shouldIgnoreRedirect };
@@ -0,0 +1,12 @@
1
+ import { splitUrlTarget } from "@modern-js/runtime-utils/url";
2
+ import { canonicalTargetPathname, localiseTargetPathname } from "./localisedUrls/index.mjs";
3
+ const buildLocalizedUrl = (target, language, languages, localisedUrls)=>{
4
+ const { pathname, search, hash } = splitUrlTarget(target);
5
+ return `${localiseTargetPathname(pathname, language, languages, localisedUrls)}${search}${hash}`;
6
+ };
7
+ const localizePath = (target, language, config)=>buildLocalizedUrl(target, language, config.languages, config.localisedUrls);
8
+ const canonicalPath = (target, config)=>{
9
+ const { pathname, search, hash } = splitUrlTarget(target);
10
+ return `${canonicalTargetPathname(pathname, config.languages, config.localisedUrls)}${search}${hash}`;
11
+ };
12
+ export { buildLocalizedUrl, canonicalPath, localizePath };
@@ -1,4 +1,5 @@
1
1
  import "node:module";
2
2
  export * from "./language-sync/controller.mjs";
3
3
  export * from "./language-sync/react.mjs";
4
+ export * from "./localeRedirectPolicy.mjs";
4
5
  export * from "./localisedUrls/index.mjs";
@@ -0,0 +1,47 @@
1
+ import "node:module";
2
+ import { isDefaultLocaleRedirectSkipPath, localiseTargetPathname, matchesPathPrefix, shouldSkipLocaleRedirect, stripLanguagePrefix } from "./localisedUrls/index.mjs";
3
+ const stripUrlPathPrefix = (pathname, urlPath)=>{
4
+ const basePath = urlPath.replace('/*', '');
5
+ if (!basePath || '/' === basePath) return pathname;
6
+ const remainingPath = pathname.startsWith(basePath) ? pathname.slice(basePath.length) : pathname;
7
+ return remainingPath || '/';
8
+ };
9
+ const shouldIgnoreRedirect = (pathname, urlPath, ignoreRedirectRoutes)=>{
10
+ const remainingPath = stripUrlPathPrefix(pathname, urlPath);
11
+ return shouldSkipLocaleRedirect(remainingPath, [], ignoreRedirectRoutes);
12
+ };
13
+ const isStaticResourceRequest = (pathname, staticRoutePrefixes, languages = [])=>{
14
+ if (isDefaultLocaleRedirectSkipPath(pathname, languages)) return true;
15
+ const matchesStaticRoutePrefix = (targetPathname)=>staticRoutePrefixes.some((prefix)=>matchesPathPrefix(targetPathname, prefix));
16
+ if (matchesStaticRoutePrefix(pathname)) return true;
17
+ const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
18
+ return pathWithoutLanguage !== pathname && matchesStaticRoutePrefix(pathWithoutLanguage);
19
+ };
20
+ const getLanguageFromPath = (req, urlPath, languages)=>{
21
+ const url = new URL(req.url, `http://${req.header().host}`);
22
+ const pathname = url.pathname;
23
+ const remainingPath = stripUrlPathPrefix(pathname, urlPath);
24
+ const segments = remainingPath.split('/').filter(Boolean);
25
+ const firstSegment = segments[0];
26
+ if (languages.includes(firstSegment)) return firstSegment;
27
+ return null;
28
+ };
29
+ const buildLocalizedUrl = (req, urlPath, language, languages, localisedUrls)=>{
30
+ const url = new URL(req.url);
31
+ const pathname = url.pathname;
32
+ const basePath = urlPath.replace('/*', '');
33
+ const remainingPath = stripUrlPathPrefix(pathname, urlPath);
34
+ const newPathname = localiseTargetPathname(remainingPath, language, languages, localisedUrls);
35
+ const suffix = `${url.search}${url.hash}`;
36
+ const localizedUrl = '/' === basePath ? newPathname + suffix : basePath + newPathname + suffix;
37
+ return localizedUrl;
38
+ };
39
+ const createLocaleRedirectResponse = (location)=>new Response(null, {
40
+ status: 302,
41
+ headers: {
42
+ 'Cache-Control': 'private, no-store',
43
+ Location: location,
44
+ Vary: 'Accept-Language, Cookie'
45
+ }
46
+ });
47
+ export { buildLocalizedUrl, createLocaleRedirectResponse, getLanguageFromPath, isStaticResourceRequest, shouldIgnoreRedirect };
@@ -0,0 +1,13 @@
1
+ import "node:module";
2
+ import { splitUrlTarget } from "@modern-js/runtime-utils/url";
3
+ import { canonicalTargetPathname, localiseTargetPathname } from "./localisedUrls/index.mjs";
4
+ const buildLocalizedUrl = (target, language, languages, localisedUrls)=>{
5
+ const { pathname, search, hash } = splitUrlTarget(target);
6
+ return `${localiseTargetPathname(pathname, language, languages, localisedUrls)}${search}${hash}`;
7
+ };
8
+ const localizePath = (target, language, config)=>buildLocalizedUrl(target, language, config.languages, config.localisedUrls);
9
+ const canonicalPath = (target, config)=>{
10
+ const { pathname, search, hash } = splitUrlTarget(target);
11
+ return `${canonicalTargetPathname(pathname, config.languages, config.localisedUrls)}${search}${hash}`;
12
+ };
13
+ export { buildLocalizedUrl, canonicalPath, localizePath };
@@ -1,3 +1,4 @@
1
1
  export * from './language-sync/controller.js';
2
2
  export * from './language-sync/react.js';
3
+ export * from './localeRedirectPolicy.js';
3
4
  export * from './localisedUrls/index.js';
@@ -0,0 +1,22 @@
1
+ import type { LocalisedUrlsOption } from './localisedUrls/index.js';
2
+ interface LocaleRedirectRequest {
3
+ url: string;
4
+ header: () => {
5
+ host?: string;
6
+ };
7
+ }
8
+ /**
9
+ * Check if pathname should ignore automatic locale redirect.
10
+ */
11
+ export declare const shouldIgnoreRedirect: (pathname: string, urlPath: string, ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean)) => boolean;
12
+ /**
13
+ * Check if pathname is a static or federation resource request.
14
+ *
15
+ * This includes configured staticRoutePrefixes, the shared default skip policy
16
+ * from ADR-0002, and language-prefixed variants such as /en/static/app.js.
17
+ */
18
+ export declare const isStaticResourceRequest: (pathname: string, staticRoutePrefixes: string[], languages?: string[]) => boolean;
19
+ export declare const getLanguageFromPath: (req: LocaleRedirectRequest, urlPath: string, languages: string[]) => string | null;
20
+ export declare const buildLocalizedUrl: (req: LocaleRedirectRequest, urlPath: string, language: string, languages: string[], localisedUrls?: LocalisedUrlsOption) => string;
21
+ export declare const createLocaleRedirectResponse: (location: string) => Response;
22
+ export {};
@@ -0,0 +1,9 @@
1
+ import { type LocalisedUrlsOption } from './localisedUrls/index.js';
2
+ export interface LocalizedPathsConfig {
3
+ languages: string[];
4
+ localisedUrls?: LocalisedUrlsOption;
5
+ }
6
+ /** Synchronous mapped URL construction, independent of React/plugin setup. */
7
+ export declare const buildLocalizedUrl: (target: string, language: string, languages: string[], localisedUrls?: LocalisedUrlsOption) => string;
8
+ export declare const localizePath: (target: string, language: string, config: LocalizedPathsConfig) => string;
9
+ export declare const canonicalPath: (target: string, config: LocalizedPathsConfig) => string;
package/package.json CHANGED
@@ -17,7 +17,7 @@
17
17
  "modern.js",
18
18
  "i18n"
19
19
  ],
20
- "version": "3.9.0-ultramodern.4",
20
+ "version": "3.9.0-ultramodern.5",
21
21
  "types": "./dist/types/index.d.ts",
22
22
  "main": "./dist/cjs/index.js",
23
23
  "module": "./dist/esm/index.mjs",
@@ -30,13 +30,24 @@
30
30
  },
31
31
  "default": "./dist/esm/index.mjs"
32
32
  },
33
- "./package.json": "./package.json"
33
+ "./package.json": "./package.json",
34
+ "./paths": {
35
+ "types": "./dist/types/paths.d.ts",
36
+ "node": {
37
+ "import": "./dist/esm-node/paths.mjs",
38
+ "require": "./dist/cjs/paths.js"
39
+ },
40
+ "default": "./dist/esm/paths.mjs"
41
+ }
34
42
  },
35
43
  "typesVersions": {
36
44
  "*": {
37
45
  ".": [
38
46
  "./dist/types/index.d.ts",
39
47
  "./src/index.ts"
48
+ ],
49
+ "paths": [
50
+ "./dist/types/paths.d.ts"
40
51
  ]
41
52
  }
42
53
  },
@@ -44,14 +55,15 @@
44
55
  "node": ">=26.7.0"
45
56
  },
46
57
  "dependencies": {
58
+ "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.5",
47
59
  "i18next-browser-languagedetector": "^8.2.1"
48
60
  },
49
61
  "peerDependencies": {
50
- "@modern-js/runtime": "3.9.0-ultramodern.4",
62
+ "@modern-js/runtime": "3.9.0-ultramodern.5",
51
63
  "react": "^19.2.8"
52
64
  },
53
65
  "devDependencies": {
54
- "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.9.0-ultramodern.4",
66
+ "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.9.0-ultramodern.5",
55
67
  "@rslib/core": "1.0.0",
56
68
  "@scripts/rstest-config": "2.66.0",
57
69
  "@types/node": "^26.4.1",
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './language-sync/controller';
2
2
  export * from './language-sync/react';
3
+ export * from './localeRedirectPolicy';
3
4
  export * from './localisedUrls/index';
@@ -0,0 +1,127 @@
1
+ import type { LocalisedUrlsOption } from './localisedUrls/index';
2
+ import {
3
+ isDefaultLocaleRedirectSkipPath,
4
+ localiseTargetPathname,
5
+ matchesPathPrefix,
6
+ shouldSkipLocaleRedirect,
7
+ stripLanguagePrefix,
8
+ } from './localisedUrls/index';
9
+
10
+ interface LocaleRedirectRequest {
11
+ url: string;
12
+ header: () => {
13
+ host?: string;
14
+ };
15
+ }
16
+
17
+ const stripUrlPathPrefix = (pathname: string, urlPath: string): string => {
18
+ const basePath = urlPath.replace('/*', '');
19
+
20
+ if (!basePath || basePath === '/') {
21
+ return pathname;
22
+ }
23
+
24
+ const remainingPath = pathname.startsWith(basePath)
25
+ ? pathname.slice(basePath.length)
26
+ : pathname;
27
+
28
+ return remainingPath || '/';
29
+ };
30
+
31
+ /**
32
+ * Check if pathname should ignore automatic locale redirect.
33
+ */
34
+ export const shouldIgnoreRedirect = (
35
+ pathname: string,
36
+ urlPath: string,
37
+ ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
38
+ ): boolean => {
39
+ const remainingPath = stripUrlPathPrefix(pathname, urlPath);
40
+
41
+ return shouldSkipLocaleRedirect(remainingPath, [], ignoreRedirectRoutes);
42
+ };
43
+
44
+ /**
45
+ * Check if pathname is a static or federation resource request.
46
+ *
47
+ * This includes configured staticRoutePrefixes, the shared default skip policy
48
+ * from ADR-0002, and language-prefixed variants such as /en/static/app.js.
49
+ */
50
+ export const isStaticResourceRequest = (
51
+ pathname: string,
52
+ staticRoutePrefixes: string[],
53
+ languages: string[] = [],
54
+ ): boolean => {
55
+ if (isDefaultLocaleRedirectSkipPath(pathname, languages)) {
56
+ return true;
57
+ }
58
+
59
+ const matchesStaticRoutePrefix = (targetPathname: string): boolean =>
60
+ staticRoutePrefixes.some(prefix =>
61
+ matchesPathPrefix(targetPathname, prefix),
62
+ );
63
+
64
+ if (matchesStaticRoutePrefix(pathname)) {
65
+ return true;
66
+ }
67
+
68
+ const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
69
+
70
+ return (
71
+ pathWithoutLanguage !== pathname &&
72
+ matchesStaticRoutePrefix(pathWithoutLanguage)
73
+ );
74
+ };
75
+
76
+ export const getLanguageFromPath = (
77
+ req: LocaleRedirectRequest,
78
+ urlPath: string,
79
+ languages: string[],
80
+ ): string | null => {
81
+ const url = new URL(req.url, `http://${req.header().host}`);
82
+ const pathname = url.pathname;
83
+ const remainingPath = stripUrlPathPrefix(pathname, urlPath);
84
+ const segments = remainingPath.split('/').filter(Boolean);
85
+ const firstSegment = segments[0];
86
+
87
+ if (languages.includes(firstSegment)) {
88
+ return firstSegment;
89
+ }
90
+
91
+ return null;
92
+ };
93
+
94
+ export const buildLocalizedUrl = (
95
+ req: LocaleRedirectRequest,
96
+ urlPath: string,
97
+ language: string,
98
+ languages: string[],
99
+ localisedUrls?: LocalisedUrlsOption,
100
+ ): string => {
101
+ const url = new URL(req.url);
102
+ const pathname = url.pathname;
103
+ const basePath = urlPath.replace('/*', '');
104
+ const remainingPath = stripUrlPathPrefix(pathname, urlPath);
105
+
106
+ const newPathname = localiseTargetPathname(
107
+ remainingPath,
108
+ language,
109
+ languages,
110
+ localisedUrls,
111
+ );
112
+ const suffix = `${url.search}${url.hash}`;
113
+ const localizedUrl =
114
+ basePath === '/' ? newPathname + suffix : basePath + newPathname + suffix;
115
+
116
+ return localizedUrl;
117
+ };
118
+
119
+ export const createLocaleRedirectResponse = (location: string): Response =>
120
+ new Response(null, {
121
+ status: 302,
122
+ headers: {
123
+ 'Cache-Control': 'private, no-store',
124
+ Location: location,
125
+ Vary: 'Accept-Language, Cookie',
126
+ },
127
+ });
package/src/paths.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { splitUrlTarget } from '@modern-js/runtime-utils/url';
2
+ import {
3
+ canonicalTargetPathname,
4
+ type LocalisedUrlsOption,
5
+ localiseTargetPathname,
6
+ } from './localisedUrls';
7
+
8
+ export interface LocalizedPathsConfig {
9
+ languages: string[];
10
+ localisedUrls?: LocalisedUrlsOption;
11
+ }
12
+
13
+ /** Synchronous mapped URL construction, independent of React/plugin setup. */
14
+ export const buildLocalizedUrl = (
15
+ target: string,
16
+ language: string,
17
+ languages: string[],
18
+ localisedUrls?: LocalisedUrlsOption,
19
+ ): string => {
20
+ const { pathname, search, hash } = splitUrlTarget(target);
21
+ return `${localiseTargetPathname(pathname, language, languages, localisedUrls)}${search}${hash}`;
22
+ };
23
+
24
+ export const localizePath = (
25
+ target: string,
26
+ language: string,
27
+ config: LocalizedPathsConfig,
28
+ ): string =>
29
+ buildLocalizedUrl(target, language, config.languages, config.localisedUrls);
30
+
31
+ export const canonicalPath = (
32
+ target: string,
33
+ config: LocalizedPathsConfig,
34
+ ): string => {
35
+ const { pathname, search, hash } = splitUrlTarget(target);
36
+ return `${canonicalTargetPathname(pathname, config.languages, config.localisedUrls)}${search}${hash}`;
37
+ };
@@ -0,0 +1,205 @@
1
+ import { describe, expect, test } from '@rstest/core';
2
+ import {
3
+ buildLocalizedUrl,
4
+ createLocaleRedirectResponse,
5
+ getLanguageFromPath,
6
+ isStaticResourceRequest,
7
+ shouldIgnoreRedirect,
8
+ } from '../src/localeRedirectPolicy';
9
+
10
+ const languages = ['en', 'cs'];
11
+ const request = (url: string) => ({
12
+ url,
13
+ header: () => ({ host: 'example.com' }),
14
+ });
15
+
16
+ describe('locale redirect resource policy', () => {
17
+ test('exempts default static and federation routes, including locale prefixes', () => {
18
+ const paths = [
19
+ '/backend-mf-manifest.json',
20
+ '/backendRemoteEntry.cjs',
21
+ '/mf-manifest.json',
22
+ '/mf-stats.json',
23
+ '/remoteEntry.js',
24
+ '/static/app.js',
25
+ '/upload/avatar.png',
26
+ ];
27
+
28
+ for (const pathname of paths) {
29
+ expect(isStaticResourceRequest(pathname, [], languages)).toBe(true);
30
+ expect(isStaticResourceRequest(`/cs${pathname}`, [], languages)).toBe(
31
+ true,
32
+ );
33
+ expect(shouldIgnoreRedirect(`/store${pathname}`, '/store/*')).toBe(true);
34
+ }
35
+ });
36
+
37
+ test('configured static prefixes match complete path segments with or without a locale', () => {
38
+ for (const pathname of ['/assets', '/assets/app.js', '/en/assets/app.js']) {
39
+ expect(isStaticResourceRequest(pathname, ['/assets/'], languages)).toBe(
40
+ true,
41
+ );
42
+ }
43
+ for (const pathname of [
44
+ '/assets-extra/app.js',
45
+ '/cs/assets-extra/app.js',
46
+ ]) {
47
+ expect(isStaticResourceRequest(pathname, ['/assets'], languages)).toBe(
48
+ false,
49
+ );
50
+ }
51
+ expect(
52
+ isStaticResourceRequest('/fr/assets/app.js', ['/assets'], languages),
53
+ ).toBe(false);
54
+ expect(
55
+ isStaticResourceRequest('/remoteEntry.js/product', [], languages),
56
+ ).toBe(false);
57
+ expect(isStaticResourceRequest('/static-product', [])).toBe(false);
58
+ });
59
+
60
+ test('custom ignore lists operate after stripping the mount path', () => {
61
+ expect(
62
+ shouldIgnoreRedirect('/store/health/live', '/store/*', ['/health']),
63
+ ).toBe(true);
64
+ expect(
65
+ shouldIgnoreRedirect('/store/healthy', '/store/*', ['/health']),
66
+ ).toBe(false);
67
+ expect(shouldIgnoreRedirect('/store/products', '/store/*')).toBe(false);
68
+ });
69
+
70
+ test('custom callbacks receive the remaining pathname and default exemptions take priority', () => {
71
+ const seen: string[] = [];
72
+ const ignore = (pathname: string) => {
73
+ seen.push(pathname);
74
+ return pathname === '/health';
75
+ };
76
+
77
+ expect(shouldIgnoreRedirect('/store/health', '/store/*', ignore)).toBe(
78
+ true,
79
+ );
80
+ expect(shouldIgnoreRedirect('/store/products', '/store/*', ignore)).toBe(
81
+ false,
82
+ );
83
+ expect(
84
+ shouldIgnoreRedirect('/store/static/app.js', '/store/*', ignore),
85
+ ).toBe(true);
86
+ expect(seen).toEqual(['/health', '/products']);
87
+ });
88
+
89
+ test('preserves startsWith mount removal and empty remaining path behavior', () => {
90
+ expect(shouldIgnoreRedirect('/storefront', '/store/*', ['/front'])).toBe(
91
+ true,
92
+ );
93
+ expect(shouldIgnoreRedirect('/store', '/store/*', ['/'])).toBe(true);
94
+ expect(shouldIgnoreRedirect('/health', '', ['/health'])).toBe(true);
95
+ expect(shouldIgnoreRedirect('/health', '/', ['/health'])).toBe(true);
96
+ expect(shouldIgnoreRedirect('/health', '/other/*', ['/health'])).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe('locale redirect URL policy', () => {
101
+ test('detects a configured first language segment for relative or absolute URLs', () => {
102
+ expect(
103
+ getLanguageFromPath(
104
+ request('/store/cs/products?x=1'),
105
+ '/store/*',
106
+ languages,
107
+ ),
108
+ ).toBe('cs');
109
+ expect(
110
+ getLanguageFromPath(
111
+ request('https://example.com/en/products'),
112
+ '/',
113
+ languages,
114
+ ),
115
+ ).toBe('en');
116
+ expect(getLanguageFromPath(request('/cs/products'), '', languages)).toBe(
117
+ 'cs',
118
+ );
119
+ expect(
120
+ getLanguageFromPath(request('/storecs/products'), '/store/*', languages),
121
+ ).toBe('cs');
122
+ });
123
+
124
+ test('retains case-sensitive language detection and does not search later segments', () => {
125
+ for (const pathname of [
126
+ '/store',
127
+ '/store/CS/products',
128
+ '/store/fr/products',
129
+ '/store/products/cs',
130
+ ]) {
131
+ expect(
132
+ getLanguageFromPath(request(pathname), '/store/*', languages),
133
+ ).toBeNull();
134
+ }
135
+ });
136
+
137
+ test('switches localized dynamic paths while preserving mount, query and hash', () => {
138
+ expect(
139
+ buildLocalizedUrl(
140
+ request(
141
+ 'https://example.com/store/en/products/red%20tractor?q=a%2Fb&sort=price#details',
142
+ ),
143
+ '/store/*',
144
+ 'cs',
145
+ languages,
146
+ { '/products/:slug': { en: '/products/:slug', cs: '/produkty/:slug' } },
147
+ ),
148
+ ).toBe('/store/cs/produkty/red%20tractor?q=a%2Fb&sort=price#details');
149
+ });
150
+
151
+ test('supports root and empty mounts and missing locale mapping', () => {
152
+ for (const mount of ['/', '', '/*']) {
153
+ expect(
154
+ buildLocalizedUrl(
155
+ request('https://example.com/products?x=1#top'),
156
+ mount,
157
+ 'cs',
158
+ languages,
159
+ ),
160
+ ).toBe('/cs/products?x=1#top');
161
+ }
162
+ expect(
163
+ buildLocalizedUrl(
164
+ request('https://example.com/store'),
165
+ '/store/*',
166
+ 'cs',
167
+ languages,
168
+ false,
169
+ ),
170
+ ).toBe('/store/cs');
171
+ });
172
+
173
+ test('preserves the existing nonmatching and partial mount behavior', () => {
174
+ expect(
175
+ buildLocalizedUrl(
176
+ request('https://example.com/products'),
177
+ '/store/*',
178
+ 'cs',
179
+ languages,
180
+ ),
181
+ ).toBe('/store/cs/products');
182
+ expect(
183
+ buildLocalizedUrl(
184
+ request('https://example.com/storefront'),
185
+ '/store/*',
186
+ 'cs',
187
+ languages,
188
+ ),
189
+ ).toBe('/store/cs/front');
190
+ });
191
+
192
+ test('returns a non-cacheable temporary redirect with language negotiation headers', async () => {
193
+ const response = createLocaleRedirectResponse(
194
+ '/cs/produkty?sort=price#details',
195
+ );
196
+
197
+ expect(response.status).toBe(302);
198
+ expect(response.headers.get('Location')).toBe(
199
+ '/cs/produkty?sort=price#details',
200
+ );
201
+ expect(response.headers.get('Cache-Control')).toBe('private, no-store');
202
+ expect(response.headers.get('Vary')).toBe('Accept-Language, Cookie');
203
+ expect(await response.text()).toBe('');
204
+ });
205
+ });
@@ -25,13 +25,11 @@ const createRoute = (
25
25
  });
26
26
 
27
27
  describe('resolveLocalisedUrlsConfig', () => {
28
- test('is opt-in: only a non-empty map enables the feature', () => {
28
+ test('enables the feature only for a non-empty map', () => {
29
29
  const map = { '/about': { en: '/about', cs: '/o-nas' } };
30
- expect(resolveLocalisedUrlsConfig(map)).toEqual({ enabled: true, map });
31
- });
32
-
33
- test('absent, boolean and empty-map options resolve to disabled', () => {
34
30
  const disabled = { enabled: false, map: {} };
31
+
32
+ expect(resolveLocalisedUrlsConfig(map)).toEqual({ enabled: true, map });
35
33
  expect(resolveLocalisedUrlsConfig(undefined)).toEqual(disabled);
36
34
  expect(resolveLocalisedUrlsConfig(false)).toEqual(disabled);
37
35
  expect(resolveLocalisedUrlsConfig(true)).toEqual(disabled);
@@ -207,9 +205,6 @@ describe('localisedUrls', () => {
207
205
  localisedUrls,
208
206
  );
209
207
 
210
- expect(
211
- applyLocalisedUrlsToRoutes(routes, ['en', 'cs', 'de'], localisedUrls),
212
- ).toEqual(localisedRoutes);
213
208
  expect(localisedRoutes.map(route => route.path)).toEqual([
214
209
  ':lang/about',
215
210
  ':lang/o.nas',
@@ -304,62 +299,6 @@ describe('localisedUrls', () => {
304
299
  ]);
305
300
  });
306
301
 
307
- test('expands flat locale-prefixed route paths with canonical keys', () => {
308
- const routes = [
309
- createRoute('/:lang/about'),
310
- createRoute('/:lang/products/:slug'),
311
- ];
312
-
313
- const localisedRoutes = applyLocalisedUrlsToRoutes(routes, ['en', 'cs'], {
314
- '/about': {
315
- en: '/about',
316
- cs: '/o-nas',
317
- },
318
- '/products/:slug': {
319
- en: '/products/:slug',
320
- cs: '/produkty/:slug',
321
- },
322
- });
323
-
324
- expect(localisedRoutes.map(route => route.path)).toEqual([
325
- ':lang/about',
326
- ':lang/o-nas',
327
- ':lang/products/:slug',
328
- ':lang/produkty/:slug',
329
- ]);
330
- });
331
-
332
- test('resolves current localized path to the target language', () => {
333
- const localisedUrls = {
334
- '/terms-of-service': {
335
- en: '/terms-of-service',
336
- cs: '/podminky-pouzivani',
337
- },
338
- '/products/:slug': {
339
- en: '/products/:slug',
340
- cs: '/produkty/:slug',
341
- },
342
- };
343
-
344
- expect(
345
- resolveLocalisedPath(
346
- '/terms-of-service',
347
- 'cs',
348
- ['en', 'cs'],
349
- localisedUrls,
350
- ),
351
- ).toBe('/podminky-pouzivani');
352
- expect(
353
- resolveLocalisedPath('/produkty/cervena-bota', 'en', ['en', 'cs'], {
354
- ...localisedUrls,
355
- '/products/:slug': {
356
- en: '/products/:slug',
357
- cs: '/produkty/:slug',
358
- },
359
- }),
360
- ).toBe('/products/cervena-bota');
361
- });
362
-
363
302
  test('resolves optional route params', () => {
364
303
  const localisedUrls = {
365
304
  '/products/:slug?': {
@@ -455,30 +394,6 @@ describe('localisedUrls', () => {
455
394
  ).toBe('/products/new');
456
395
  });
457
396
 
458
- test('reverse resolution is deterministic for equally specific matches', () => {
459
- const firstOrder = {
460
- '/z/:slug': {
461
- en: '/x/:slug',
462
- cs: '/z/:slug',
463
- },
464
- '/a/:section': {
465
- en: '/:section/y',
466
- cs: '/a/:section',
467
- },
468
- };
469
- const reverseOrder = {
470
- '/a/:section': firstOrder['/a/:section'],
471
- '/z/:slug': firstOrder['/z/:slug'],
472
- };
473
-
474
- expect(
475
- resolveCanonicalLocalisedPath('/x/y', ['en', 'cs'], firstOrder),
476
- ).toBe('/a/x');
477
- expect(
478
- resolveCanonicalLocalisedPath('/x/y', ['en', 'cs'], reverseOrder),
479
- ).toBe('/a/x');
480
- });
481
-
482
397
  test('reverse resolution prefers the most specific localised source pattern', () => {
483
398
  const localisedUrls = {
484
399
  '/catalog/:section/:item/:detail': {
@@ -537,31 +452,6 @@ describe('localisedUrls', () => {
537
452
  ).toThrow('Missing required path parameter "toString"');
538
453
  });
539
454
 
540
- test('localises and canonicalises full target pathnames through one helper', () => {
541
- const localisedUrls = {
542
- '/products/:slug': {
543
- en: '/products/:slug',
544
- cs: '/produkty/:slug',
545
- },
546
- };
547
-
548
- expect(
549
- localiseTargetPathname(
550
- '/en/products/cervena-bota',
551
- 'cs',
552
- ['en', 'cs'],
553
- localisedUrls,
554
- ),
555
- ).toBe('/cs/produkty/cervena-bota');
556
- expect(
557
- canonicalTargetPathname(
558
- '/cs/produkty/cervena-bota',
559
- ['en', 'cs'],
560
- localisedUrls,
561
- ),
562
- ).toBe('/products/cervena-bota');
563
- });
564
-
565
455
  test('strips case-insensitive locale prefixes before relocalising full pathnames', () => {
566
456
  const localisedUrls = {
567
457
  '/products/:slug': {
@@ -0,0 +1,189 @@
1
+ import { describe, expect, test } from '@rstest/core';
2
+ import type { LocalisedUrlsMap } from '../src/localisedUrls';
3
+ import { buildLocalizedUrl, canonicalPath, localizePath } from '../src/paths';
4
+
5
+ const languages = ['en', 'cs'];
6
+
7
+ const localisedUrls = {
8
+ '/products/:slug': {
9
+ en: '/products/:slug',
10
+ cs: '/produkty/:slug',
11
+ },
12
+ '/files/*': {
13
+ en: '/files/*',
14
+ cs: '/soubory/*',
15
+ },
16
+ '/app/products/:slug': {
17
+ en: '/app/products/:slug',
18
+ cs: '/app/produkty/:slug',
19
+ },
20
+ } satisfies LocalisedUrlsMap;
21
+
22
+ const pathsConfig = {
23
+ languages,
24
+ localisedUrls,
25
+ };
26
+
27
+ describe('fork-owned localised URL rewrite matrix', () => {
28
+ const rewriteScenarios = [
29
+ {
30
+ name: 'adds the target locale prefix and localises canonical segments',
31
+ target: '/products/red-shoe',
32
+ language: 'cs',
33
+ expected: '/cs/produkty/red-shoe',
34
+ },
35
+ {
36
+ name: 'strips a case-insensitive source locale before relocalising',
37
+ target: '/CS/produkty/red%20shoe?tag=boots#details',
38
+ language: 'en',
39
+ expected: '/en/products/red%20shoe?tag=boots#details',
40
+ },
41
+ {
42
+ name: 'preserves repeated query keys and the fragment',
43
+ target: '/products/red-shoe?tag=boots&tag=sale#details',
44
+ language: 'cs',
45
+ expected: '/cs/produkty/red-shoe?tag=boots&tag=sale#details',
46
+ },
47
+ {
48
+ name: 'keeps a base-path segment inside the localised route map',
49
+ target: '/app/products/red-shoe?ref=nav#details',
50
+ language: 'cs',
51
+ expected: '/cs/app/produkty/red-shoe?ref=nav#details',
52
+ },
53
+ {
54
+ name: 'keeps the default locale explicitly prefixed',
55
+ target: '/products/red-shoe',
56
+ language: 'en',
57
+ expected: '/en/products/red-shoe',
58
+ },
59
+ {
60
+ name: 'does not double-prefix an already-localised URL',
61
+ target: '/cs/produkty/red-shoe?tag=boots#details',
62
+ language: 'cs',
63
+ expected: '/cs/produkty/red-shoe?tag=boots#details',
64
+ },
65
+ ];
66
+
67
+ for (const scenario of rewriteScenarios) {
68
+ test(scenario.name, () => {
69
+ expect(
70
+ buildLocalizedUrl(
71
+ scenario.target,
72
+ scenario.language,
73
+ languages,
74
+ localisedUrls,
75
+ ),
76
+ ).toBe(scenario.expected);
77
+ });
78
+ }
79
+
80
+ test('canonical and localized path helpers strip and add prefixes exactly', () => {
81
+ expect(
82
+ canonicalPath('/CS/produkty/red-shoe?tag=boots#details', pathsConfig),
83
+ ).toBe('/products/red-shoe?tag=boots#details');
84
+ expect(
85
+ localizePath('/products/red-shoe?tag=boots#details', 'cs', pathsConfig),
86
+ ).toBe('/cs/produkty/red-shoe?tag=boots#details');
87
+ });
88
+
89
+ test('splat params preserve separators and percent-encode each segment', () => {
90
+ expect(
91
+ buildLocalizedUrl(
92
+ '/files/resume%20drafts/Q1%20deck.pdf',
93
+ 'cs',
94
+ languages,
95
+ localisedUrls,
96
+ ),
97
+ ).toBe('/cs/soubory/resume%20drafts/Q1%20deck.pdf');
98
+ });
99
+ test('explicit maps work without plugin registration and keep suffixes verbatim', () => {
100
+ const config = {
101
+ languages,
102
+ localisedUrls: {
103
+ '/docs/:slug?': { en: '/docs/:slug?', cs: '/dokumenty/:slug?' },
104
+ },
105
+ };
106
+ expect(localizePath('/docs?x=%2f&x=2#literal?query', 'cs', config)).toBe(
107
+ '/cs/dokumenty?x=%2f&x=2#literal?query',
108
+ );
109
+ expect(canonicalPath('/cs/dokumenty/%E0%A4%A', config)).toBe(
110
+ '/dokumenty/%E0%A4%A',
111
+ );
112
+ expect(localizePath('/[literal]?x=1#x', 'cs', { languages })).toBe(
113
+ '/cs/[literal]?x=1#x',
114
+ );
115
+ });
116
+ });
117
+
118
+ describe('migrated public mapped helper contract', () => {
119
+ const localisedUrls = {
120
+ '/terms-of-service': {
121
+ en: '/terms-of-service',
122
+ cs: '/podminky-pouzivani',
123
+ },
124
+ '/products': {
125
+ en: '/products',
126
+ cs: '/produkty',
127
+ },
128
+ '/products/:slug': {
129
+ en: '/products/:slug',
130
+ cs: '/produkty/:slug',
131
+ },
132
+ // Canonical key that matches no language pattern.
133
+ '/talks/:slug': {
134
+ en: '/lectures/:slug',
135
+ cs: '/prednasky/:slug',
136
+ },
137
+ };
138
+
139
+ const languages = ['en', 'cs'];
140
+ const pathsConfig = { languages, localisedUrls };
141
+
142
+ test('query and hash survive localized slug mapping', () => {
143
+ expect(
144
+ buildLocalizedUrl(
145
+ '/products/bota?tag=x#detail',
146
+ 'cs',
147
+ languages,
148
+ localisedUrls,
149
+ ),
150
+ ).toBe('/cs/produkty/bota?tag=x#detail');
151
+ });
152
+
153
+ test('localizes canonical keys that match no language pattern', () => {
154
+ expect(
155
+ buildLocalizedUrl(
156
+ '/talks/ai-slop#abstract',
157
+ 'cs',
158
+ languages,
159
+ localisedUrls,
160
+ ),
161
+ ).toBe('/cs/prednasky/ai-slop#abstract');
162
+ expect(
163
+ buildLocalizedUrl('/talks/ai-slop', 'en', languages, localisedUrls),
164
+ ).toBe('/en/lectures/ai-slop');
165
+ });
166
+
167
+ test('re-localizes already-localized paths', () => {
168
+ expect(
169
+ buildLocalizedUrl('/cs/produkty/bota#x', 'en', languages, localisedUrls),
170
+ ).toBe('/en/products/bota#x');
171
+ });
172
+ test('localizePath maps canonical paths per language', () => {
173
+ expect(localizePath('/products/bota', 'cs', pathsConfig)).toBe(
174
+ '/cs/produkty/bota',
175
+ );
176
+ expect(localizePath('/talks/x', 'en', pathsConfig)).toBe('/en/lectures/x');
177
+ });
178
+
179
+ test('canonicalPath strips language and reverse-maps localized slugs', () => {
180
+ expect(canonicalPath('/cs/produkty/bota', pathsConfig)).toBe(
181
+ '/products/bota',
182
+ );
183
+ expect(canonicalPath('/en/lectures/x?q=1#h', pathsConfig)).toBe(
184
+ '/talks/x?q=1#h',
185
+ );
186
+ expect(canonicalPath('/cs', pathsConfig)).toBe('/');
187
+ expect(canonicalPath('/en/products', pathsConfig)).toBe('/products');
188
+ });
189
+ });
@@ -7,6 +7,14 @@ interface Target {
7
7
  language: string;
8
8
  }
9
9
 
10
+ beforeEach(() => {
11
+ rstest.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
12
+ });
13
+
14
+ afterEach(() => {
15
+ rstest.unstubAllGlobals();
16
+ });
17
+
10
18
  test('an abandoned suspended render cannot publish an uncommitted target', async () => {
11
19
  let resolveFirst!: () => void;
12
20
  const firstChange = new Promise<void>(resolve => {
@@ -14,6 +22,7 @@ test('an abandoned suspended render cannot publish an uncommitted target', async
14
22
  });
15
23
  const changes: string[] = [];
16
24
  const commits: string[] = [];
25
+ let suspended = false;
17
26
  const neverSettles = new Promise<never>(() => undefined);
18
27
  const first = { id: 'first', language: 'en' };
19
28
  const speculative = { id: 'speculative', language: 'en' };
@@ -41,6 +50,7 @@ test('an abandoned suspended render cannot publish an uncommitted target', async
41
50
  },
42
51
  });
43
52
  if (suspend) {
53
+ suspended = true;
44
54
  throw neverSettles;
45
55
  }
46
56
  return <main>{target.id}</main>;
@@ -68,6 +78,11 @@ test('an abandoned suspended render cannot publish an uncommitted target', async
68
78
  await Promise.resolve();
69
79
  });
70
80
 
81
+ expect(suspended).toBe(true);
82
+ expect(container.textContent).toBe('first');
83
+ expect(changes).toEqual(['first']);
84
+ expect(commits).toEqual([]);
85
+
71
86
  await act(async () => {
72
87
  resolveFirst();
73
88
  await firstChange;
@@ -1,74 +0,0 @@
1
- import React, { act } from 'react';
2
- import { createRoot } from 'react-dom/client';
3
- import { useClientSideRedirect } from '../../plugin-i18n/src/runtime/hooks';
4
-
5
- const navigate = rstest.fn(async () => undefined);
6
- let location = {
7
- pathname: '/cs/about',
8
- search: '?from=test',
9
- hash: '#section',
10
- };
11
-
12
- rstest.mock('../../plugin-i18n/src/runtime/routerAdapter', () => ({
13
- useI18nRouterAdapter: () => ({
14
- hasRouter: true,
15
- location,
16
- navigate,
17
- }),
18
- }));
19
-
20
- test('canonicalizes successive prefixed routes in one mounted provider', async () => {
21
- const previousTarget = process.env.MODERN_TARGET;
22
- process.env.MODERN_TARGET = 'browser';
23
- const instance = {
24
- language: 'cs',
25
- isInitialized: true,
26
- } as Parameters<typeof useClientSideRedirect>[0];
27
- const localisedUrls = {
28
- '/about': { en: '/about', cs: '/o-nas' },
29
- '/terms-of-service': {
30
- en: '/terms-of-service',
31
- cs: '/podminky-pouzivani',
32
- },
33
- };
34
- const Harness = () => {
35
- useClientSideRedirect(
36
- instance,
37
- true,
38
- ['en', 'cs'],
39
- 'en',
40
- undefined,
41
- localisedUrls,
42
- );
43
- return null;
44
- };
45
- const container = document.createElement('div');
46
- const root = createRoot(container);
47
-
48
- try {
49
- window.history.replaceState(null, '', '/cs/about?from=test#section');
50
- await act(async () => root.render(<Harness />));
51
- expect(navigate).toHaveBeenLastCalledWith('/cs/o-nas?from=test#section', {
52
- replace: true,
53
- });
54
-
55
- location = {
56
- pathname: '/cs/terms-of-service',
57
- search: '',
58
- hash: '',
59
- };
60
- window.history.replaceState(null, '', '/cs/terms-of-service');
61
- await act(async () => root.render(<Harness />));
62
- expect(navigate).toHaveBeenLastCalledWith('/cs/podminky-pouzivani', {
63
- replace: true,
64
- });
65
- expect(navigate).toHaveBeenCalledTimes(2);
66
- } finally {
67
- await act(async () => root.unmount());
68
- if (previousTarget === undefined) {
69
- delete process.env.MODERN_TARGET;
70
- } else {
71
- process.env.MODERN_TARGET = previousTarget;
72
- }
73
- }
74
- });