@bleedingdev/modern-js-i18n-runtime-extensions 3.9.0-ultramodern.3 → 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.
Files changed (35) hide show
  1. package/dist/cjs/index.js +9 -2
  2. package/dist/cjs/localeRedirectPolicy.js +96 -0
  3. package/dist/cjs/localisedUrls/index.js +7 -0
  4. package/dist/cjs/localisedUrls/routeIdentity.js +118 -0
  5. package/dist/cjs/localisedUrls/routes.js +8 -0
  6. package/dist/cjs/paths.js +56 -0
  7. package/dist/esm/index.mjs +1 -0
  8. package/dist/esm/localeRedirectPolicy.mjs +46 -0
  9. package/dist/esm/localisedUrls/index.mjs +1 -0
  10. package/dist/esm/localisedUrls/routeIdentity.mjs +77 -0
  11. package/dist/esm/localisedUrls/routes.mjs +8 -0
  12. package/dist/esm/paths.mjs +12 -0
  13. package/dist/esm-node/index.mjs +1 -0
  14. package/dist/esm-node/localeRedirectPolicy.mjs +47 -0
  15. package/dist/esm-node/localisedUrls/index.mjs +1 -0
  16. package/dist/esm-node/localisedUrls/routeIdentity.mjs +78 -0
  17. package/dist/esm-node/localisedUrls/routes.mjs +8 -0
  18. package/dist/esm-node/paths.mjs +13 -0
  19. package/dist/types/index.d.ts +1 -0
  20. package/dist/types/localeRedirectPolicy.d.ts +22 -0
  21. package/dist/types/localisedUrls/index.d.ts +1 -0
  22. package/dist/types/localisedUrls/routeIdentity.d.ts +24 -0
  23. package/dist/types/paths.d.ts +9 -0
  24. package/package.json +16 -4
  25. package/src/index.ts +1 -0
  26. package/src/localeRedirectPolicy.ts +127 -0
  27. package/src/localisedUrls/index.ts +4 -0
  28. package/src/localisedUrls/routeIdentity.ts +107 -0
  29. package/src/localisedUrls/routes.ts +10 -0
  30. package/src/paths.ts +37 -0
  31. package/tests/localeRedirectPolicy.test.ts +205 -0
  32. package/tests/localisedUrls.test.ts +3 -113
  33. package/tests/paths.test.ts +189 -0
  34. package/tests/reactLanguageSync.test.tsx +20 -1
  35. package/tests/clientRedirectIntegration.test.tsx +0 -74
@@ -102,6 +102,14 @@ const transformLocalisedRoute = (route, parentCanonicalPath, parentLocalisedPath
102
102
  const children = 'children' in route && route.children ? route.children.flatMap((child)=>transformLocalisedRoute(child, canonicalPath, routeLocalisedPaths, languages, localisedUrls)) : void 0;
103
103
  const baseRoute = {
104
104
  ...route,
105
+ ...!isFrameworkInternalRoutePath(canonicalPath) ? {
106
+ modernLocalisedRoute: {
107
+ id: route.id,
108
+ path: route.path,
109
+ canonicalPath,
110
+ paths: localisedUrlEntry
111
+ }
112
+ } : {},
105
113
  ...children ? {
106
114
  children
107
115
  } : {}
@@ -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 {};
@@ -5,5 +5,6 @@ export { buildPathFromPattern, matchPathPattern } from './patterns.js';
5
5
  export type { LocaleRedirectSkipRule } from './redirect.js';
6
6
  export { DEFAULT_LOCALE_REDIRECT_SKIP_RULES, isDefaultLocaleRedirectSkipPath, matchesPathPrefix, shouldSkipLocaleRedirect, } from './redirect.js';
7
7
  export { resolveCanonicalLocalisedPath, resolveLocalisedPath } from './resolve.js';
8
+ export { canonicaliseLocalisedRoutes, createLocalisedRouteRewrite, } from './routeIdentity.js';
8
9
  export { applyLocalisedUrlsToRoutes, validateLocalisedUrls } from './routes.js';
9
10
  export type { LocalisedRoute, LocalisedUrlPathMap, LocalisedUrlsMap, LocalisedUrlsOption, ResolvedLocalisedUrlsConfig, } from './types.js';
@@ -0,0 +1,24 @@
1
+ interface RouteIdentity {
2
+ id?: string;
3
+ path?: string;
4
+ canonicalPath: string;
5
+ paths?: Record<string, string>;
6
+ }
7
+ interface RouteNode {
8
+ id?: string;
9
+ path?: string;
10
+ children?: RouteNode[];
11
+ modernLocalisedRoute?: RouteIdentity;
12
+ }
13
+ /** Restore one source route identity for routers with strict route-ID hooks. */
14
+ export declare function canonicaliseLocalisedRoutes<T extends RouteNode>(routes: T[]): T[];
15
+ /** Use the router's URL rewrite seam; browser URLs retain their locale spelling. */
16
+ export declare function createLocalisedRouteRewrite(routes: RouteNode[]): {
17
+ input: ({ url }: {
18
+ url: URL;
19
+ }) => URL;
20
+ output: ({ url }: {
21
+ url: URL;
22
+ }) => URL;
23
+ } | undefined;
24
+ 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.3",
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.3",
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.3",
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
+ });
@@ -14,6 +14,10 @@ export {
14
14
  shouldSkipLocaleRedirect,
15
15
  } from './redirect';
16
16
  export { resolveCanonicalLocalisedPath, resolveLocalisedPath } from './resolve';
17
+ export {
18
+ canonicaliseLocalisedRoutes,
19
+ createLocalisedRouteRewrite,
20
+ } from './routeIdentity';
17
21
  export { applyLocalisedUrlsToRoutes, validateLocalisedUrls } from './routes';
18
22
  export type {
19
23
  LocalisedRoute,
@@ -0,0 +1,107 @@
1
+ import { normalisePathPattern } from './normalise';
2
+ import { buildPathFromPattern, matchPathPattern } from './patterns';
3
+
4
+ interface RouteIdentity {
5
+ id?: string;
6
+ path?: string;
7
+ canonicalPath: string;
8
+ paths?: Record<string, string>;
9
+ }
10
+
11
+ interface RouteNode {
12
+ id?: string;
13
+ path?: string;
14
+ children?: RouteNode[];
15
+ modernLocalisedRoute?: RouteIdentity;
16
+ }
17
+
18
+ /** Restore one source route identity for routers with strict route-ID hooks. */
19
+ export function canonicaliseLocalisedRoutes<T extends RouteNode>(
20
+ routes: T[],
21
+ ): T[] {
22
+ const seen = new Set<string>();
23
+ return routes.flatMap(route => {
24
+ const identity = route.modernLocalisedRoute;
25
+ const key = identity?.id ?? identity?.path;
26
+ if (key !== undefined) {
27
+ if (seen.has(key)) return [];
28
+ seen.add(key);
29
+ }
30
+ return [
31
+ {
32
+ ...route,
33
+ ...(identity ? { id: identity.id, path: identity.path } : {}),
34
+ ...(route.children
35
+ ? { children: canonicaliseLocalisedRoutes(route.children) }
36
+ : {}),
37
+ },
38
+ ];
39
+ });
40
+ }
41
+
42
+ type PathRewrite = { from: string; to: string };
43
+
44
+ function rewritePath(url: URL, mappings: PathRewrite[]) {
45
+ for (const { from, to } of mappings) {
46
+ const params = matchPathPattern(url.pathname, from);
47
+ if (params) {
48
+ url.pathname = buildPathFromPattern(to, params);
49
+ break;
50
+ }
51
+ }
52
+ return url;
53
+ }
54
+
55
+ /** Use the router's URL rewrite seam; browser URLs retain their locale spelling. */
56
+ export function createLocalisedRouteRewrite(routes: RouteNode[]) {
57
+ const input: PathRewrite[] = [];
58
+ const output: PathRewrite[] = [];
59
+ const visit = (nodes: RouteNode[], parent: string) => {
60
+ for (const route of nodes) {
61
+ const fullPath = normalisePathPattern(`${parent}/${route.path ?? ''}`);
62
+ const identity = route.modernLocalisedRoute;
63
+ if (identity?.paths && fullPath.endsWith(identity.canonicalPath)) {
64
+ const prefix = fullPath.slice(0, -identity.canonicalPath.length);
65
+ const hasLocale = prefix
66
+ .split('/')
67
+ .some(segment => /^:(?:lang|locale|language)\??$/.test(segment));
68
+ for (const [language, localisedPath] of Object.entries(
69
+ identity.paths,
70
+ )) {
71
+ const localePrefix = prefix
72
+ .split('/')
73
+ .map(segment =>
74
+ /^:(?:lang|locale|language)\??$/.test(segment)
75
+ ? language
76
+ : segment,
77
+ )
78
+ .join('/');
79
+ const canonical = normalisePathPattern(
80
+ `${localePrefix}/${identity.canonicalPath}`,
81
+ );
82
+ const localised = normalisePathPattern(
83
+ `${localePrefix}/${localisedPath}`,
84
+ );
85
+ if (canonical !== localised) {
86
+ input.push({ from: localised, to: canonical });
87
+ if (hasLocale) output.push({ from: canonical, to: localised });
88
+ }
89
+ }
90
+ }
91
+ if (route.children) visit(route.children, fullPath);
92
+ }
93
+ };
94
+ visit(canonicaliseLocalisedRoutes(routes), '');
95
+ if (input.length === 0) return undefined;
96
+ const specificity = (pattern: string) =>
97
+ pattern
98
+ .split('/')
99
+ .filter(segment => segment && !segment.startsWith(':') && segment !== '*')
100
+ .length;
101
+ for (const mappings of [input, output])
102
+ mappings.sort((a, b) => specificity(b.from) - specificity(a.from));
103
+ return {
104
+ input: ({ url }: { url: URL }) => rewritePath(url, input),
105
+ output: ({ url }: { url: URL }) => rewritePath(url, output),
106
+ };
107
+ }
@@ -291,6 +291,16 @@ const transformLocalisedRoute = (
291
291
 
292
292
  const baseRoute = {
293
293
  ...route,
294
+ ...(!isFrameworkInternalRoutePath(canonicalPath)
295
+ ? {
296
+ modernLocalisedRoute: {
297
+ id: route.id,
298
+ path: route.path,
299
+ canonicalPath,
300
+ paths: localisedUrlEntry,
301
+ },
302
+ }
303
+ : {}),
294
304
  ...(children ? { children } : {}),
295
305
  } as LocalisedRoute;
296
306
 
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
+ });