@bleedingdev/modern-js-runtime-extensions 3.9.0-ultramodern.9 → 3.9.3-ultramodern.10

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 (126) hide show
  1. package/dist/cjs/{reactContext.js → localisedUrls/config.js} +14 -10
  2. package/dist/cjs/localisedUrls/index.js +95 -0
  3. package/dist/cjs/localisedUrls/normalise.js +87 -0
  4. package/dist/cjs/localisedUrls/pathname.js +68 -0
  5. package/dist/cjs/localisedUrls/patterns.js +214 -0
  6. package/dist/cjs/localisedUrls/redirect.js +104 -0
  7. package/dist/cjs/localisedUrls/resolve.js +78 -0
  8. package/dist/cjs/localisedUrls/routeIdentity.js +92 -0
  9. package/dist/cjs/localisedUrls/routes.js +217 -0
  10. package/dist/cjs/localisedUrls/types.js +18 -0
  11. package/dist/cjs/rendererHead.js +18 -5
  12. package/dist/cjs/request-policy/policyCore.js +1 -1
  13. package/dist/cjs/request-policy/transport.js +2 -2
  14. package/dist/cjs/routerState.js +85 -77
  15. package/dist/cjs/routerStatePlugin.js +63 -9
  16. package/dist/cjs/server-config/serverSource.js +18 -0
  17. package/dist/esm/localisedUrls/config.mjs +11 -0
  18. package/dist/esm/localisedUrls/index.mjs +8 -0
  19. package/dist/esm/localisedUrls/normalise.mjs +37 -0
  20. package/dist/esm/localisedUrls/pathname.mjs +24 -0
  21. package/dist/esm/localisedUrls/patterns.mjs +152 -0
  22. package/dist/esm/localisedUrls/redirect.mjs +57 -0
  23. package/dist/esm/localisedUrls/resolve.mjs +37 -0
  24. package/dist/esm/localisedUrls/routeIdentity.mjs +54 -0
  25. package/dist/esm/localisedUrls/routes.mjs +176 -0
  26. package/dist/esm/localisedUrls/types.mjs +0 -0
  27. package/dist/esm/rendererHead.mjs +14 -5
  28. package/dist/esm/request-policy/policyCore.mjs +1 -1
  29. package/dist/esm/request-policy/transport.mjs +2 -2
  30. package/dist/esm/routerState.mjs +81 -73
  31. package/dist/esm/routerStatePlugin.mjs +62 -11
  32. package/dist/esm/server-config/serverSource.mjs +0 -0
  33. package/dist/esm-node/localisedUrls/config.mjs +12 -0
  34. package/dist/esm-node/localisedUrls/index.mjs +9 -0
  35. package/dist/esm-node/localisedUrls/normalise.mjs +38 -0
  36. package/dist/esm-node/localisedUrls/pathname.mjs +25 -0
  37. package/dist/esm-node/localisedUrls/patterns.mjs +153 -0
  38. package/dist/esm-node/localisedUrls/redirect.mjs +58 -0
  39. package/dist/esm-node/localisedUrls/resolve.mjs +38 -0
  40. package/dist/esm-node/localisedUrls/routeIdentity.mjs +55 -0
  41. package/dist/esm-node/localisedUrls/routes.mjs +177 -0
  42. package/dist/esm-node/localisedUrls/types.mjs +1 -0
  43. package/dist/esm-node/rendererHead.mjs +14 -5
  44. package/dist/esm-node/request-policy/policyCore.mjs +1 -1
  45. package/dist/esm-node/request-policy/transport.mjs +2 -2
  46. package/dist/esm-node/routerState.mjs +81 -73
  47. package/dist/esm-node/routerStatePlugin.mjs +62 -11
  48. package/dist/esm-node/server-config/serverSource.mjs +1 -0
  49. package/dist/types/helmetCompat.d.ts +1 -1
  50. package/dist/types/localisedUrls/config.d.ts +9 -0
  51. package/dist/types/localisedUrls/index.d.ts +10 -0
  52. package/dist/types/localisedUrls/normalise.d.ts +10 -0
  53. package/dist/types/localisedUrls/pathname.d.ts +4 -0
  54. package/dist/types/localisedUrls/patterns.d.ts +11 -0
  55. package/dist/types/localisedUrls/redirect.d.ts +19 -0
  56. package/dist/types/localisedUrls/resolve.d.ts +7 -0
  57. package/dist/types/localisedUrls/routeIdentity.d.ts +22 -0
  58. package/dist/types/localisedUrls/routes.d.ts +3 -0
  59. package/dist/types/localisedUrls/types.d.ts +14 -0
  60. package/dist/types/rendererHead.d.ts +5 -3
  61. package/dist/types/request-policy/browser.d.ts +5 -1
  62. package/dist/types/request-policy/node.d.ts +5 -1
  63. package/dist/types/request-policy/types.d.ts +1 -1
  64. package/dist/types/routeHydration.d.ts +2 -2
  65. package/dist/types/routerProvider.d.ts +1 -3
  66. package/dist/types/routerState.d.ts +11 -6
  67. package/dist/types/routerStatePlugin.d.ts +12 -0
  68. package/dist/types/routerStateTypes.d.ts +47 -17
  69. package/dist/types/server-config/index.d.ts +1 -0
  70. package/dist/types/server-config/serverSource.d.ts +16 -0
  71. package/package.json +43 -45
  72. package/src/helmetCompat.ts +1 -1
  73. package/src/localisedUrls/config.ts +18 -0
  74. package/src/localisedUrls/index.ts +25 -0
  75. package/src/localisedUrls/normalise.ts +62 -0
  76. package/src/localisedUrls/pathname.ts +60 -0
  77. package/src/localisedUrls/patterns.ts +283 -0
  78. package/src/localisedUrls/redirect.ts +110 -0
  79. package/src/localisedUrls/resolve.ts +106 -0
  80. package/src/localisedUrls/routeIdentity.ts +83 -0
  81. package/src/localisedUrls/routes.ts +451 -0
  82. package/src/localisedUrls/types.ts +18 -0
  83. package/src/rendererHead.ts +25 -8
  84. package/src/request-policy/policyCore.ts +1 -1
  85. package/src/request-policy/transport.ts +2 -2
  86. package/src/request-policy/types.ts +1 -1
  87. package/src/routeHydration.ts +2 -2
  88. package/src/routerProvider.ts +1 -1
  89. package/src/routerState.ts +127 -115
  90. package/src/routerStatePlugin.ts +106 -10
  91. package/src/routerStateTypes.ts +47 -17
  92. package/src/server-config/index.ts +1 -0
  93. package/src/server-config/serverSource.ts +23 -0
  94. package/dist/cjs/buildIdentity.js +0 -73
  95. package/dist/esm/buildIdentity.mjs +0 -35
  96. package/dist/esm/reactContext.mjs +0 -7
  97. package/dist/esm-node/buildIdentity.mjs +0 -36
  98. package/dist/esm-node/reactContext.mjs +0 -8
  99. package/dist/types/buildIdentity.d.ts +0 -37
  100. package/dist/types/reactContext.d.ts +0 -6
  101. package/rslib.config.mts +0 -19
  102. package/rstest.config.mts +0 -38
  103. package/src/buildIdentity.ts +0 -61
  104. package/src/reactContext.ts +0 -16
  105. package/tests/buildIdentity.test.ts +0 -51
  106. package/tests/contextExtensions.test.ts +0 -27
  107. package/tests/headRuntime.test.tsx +0 -229
  108. package/tests/rendererHead.test.ts +0 -187
  109. package/tests/request-policy/browser-policy.test.ts +0 -108
  110. package/tests/request-policy/client-isolation.test.ts +0 -109
  111. package/tests/request-policy/node-policy.test.ts +0 -447
  112. package/tests/request-policy/policyCore.test.ts +0 -129
  113. package/tests/request-policy/requestContext.test.ts +0 -66
  114. package/tests/request-policy/requestFactory.outbound-contract.test.ts +0 -290
  115. package/tests/request-policy/traceparent.test.ts +0 -52
  116. package/tests/request-policy/transport-retry.test.ts +0 -367
  117. package/tests/routeHydration.test.ts +0 -180
  118. package/tests/routerPrefetchPolicy.test.ts +0 -102
  119. package/tests/routerProvider.test.ts +0 -29
  120. package/tests/routerState.test.ts +0 -78
  121. package/tests/routerStatePlugin.test.ts +0 -63
  122. package/tests/runtimeContextProjection.test.ts +0 -83
  123. package/tests/safeFailure.test.ts +0 -136
  124. package/tests/shellStream.test.ts +0 -122
  125. package/tests/workspaceEvents.test.ts +0 -221
  126. package/tsconfig.json +0 -10
@@ -1,3 +1,4 @@
1
+ import type React from 'react';
1
2
  export type BuiltInRouterFramework = 'react-router' | 'tanstack';
2
3
  export type RouterFramework = BuiltInRouterFramework | (string & {});
3
4
  export interface RouterRouteMatchSnapshot {
@@ -7,34 +8,63 @@ export interface RouterRouteMatchSnapshot {
7
8
  params?: Record<string, string>;
8
9
  }
9
10
  export interface InternalRouterServerSnapshot {
10
- framework?: RouterFramework;
11
- basename?: string;
12
- statusCode?: number;
13
- errors?: Record<string, unknown>;
14
- routerData?: {
15
- loaderData?: Record<string, unknown>;
16
- errors?: Record<string, unknown>;
11
+ readonly framework?: RouterFramework;
12
+ readonly basename?: string;
13
+ readonly statusCode?: number;
14
+ readonly errors?: Record<string, unknown>;
15
+ readonly routerData?: {
16
+ readonly loaderData?: Record<string, unknown>;
17
+ readonly errors?: Record<string, unknown>;
17
18
  };
18
- hydrationScript?: string;
19
- hydrationScripts?: string[];
20
- matchedRouteIds?: string[];
21
- matches?: RouterRouteMatchSnapshot[];
19
+ readonly hydrationScripts?: readonly string[];
20
+ readonly matchedRouteIds?: readonly string[];
21
+ readonly matches?: readonly RouterRouteMatchSnapshot[];
22
22
  }
23
23
  export interface InternalRouterRuntimeState {
24
24
  framework: RouterFramework;
25
25
  basename?: string;
26
26
  instance?: unknown;
27
- hydrationScript?: string;
28
- hydrationScripts?: string[];
29
- matchedRouteIds?: string[];
30
- matches?: RouterRouteMatchSnapshot[];
31
- serverSnapshot?: InternalRouterServerSnapshot;
27
+ navigation?: RouterNavigationCapability;
32
28
  cleanup?: () => void | Promise<void>;
33
29
  }
34
30
  export interface RouterServerPrepareResult {
35
31
  state: InternalRouterRuntimeState;
36
32
  snapshot?: InternalRouterServerSnapshot;
37
- redirect?: Response;
38
33
  cleanup?: () => void | Promise<void>;
39
34
  }
40
35
  export type RouterLifecyclePhase = 'ssr-prepare' | 'client-create' | 'hydrate';
36
+ /** Provider-owned browser navigation. Consumers never inspect router internals. */
37
+ export interface RouterNavigationSnapshot {
38
+ location: {
39
+ pathname: string;
40
+ search: string;
41
+ hash: string;
42
+ };
43
+ params: Record<string, string>;
44
+ }
45
+ export interface RouterLinkTarget {
46
+ pathname: string;
47
+ href: string;
48
+ search?: Record<string, unknown>;
49
+ hash?: string;
50
+ hashScrollIntoView?: boolean | ScrollIntoViewOptions;
51
+ prefetch?: 'intent' | 'render' | 'viewport' | 'none';
52
+ preload?: unknown;
53
+ }
54
+ export interface RouterNavigationCapability {
55
+ getSnapshot: () => RouterNavigationSnapshot;
56
+ subscribe: (listener: () => void) => () => void;
57
+ navigate: (href: string, options?: {
58
+ replace?: boolean;
59
+ state?: unknown;
60
+ }) => void | Promise<void>;
61
+ Link: React.ComponentType<{
62
+ to: string;
63
+ children?: React.ReactNode;
64
+ [key: string]: unknown;
65
+ }>;
66
+ createLinkProps?: (target: RouterLinkTarget) => {
67
+ to: string;
68
+ [key: string]: unknown;
69
+ };
70
+ }
@@ -1,2 +1,3 @@
1
1
  export type * from './bffRuntime.js';
2
+ export type * from './serverSource.js';
2
3
  export type * from './serverTelemetry.js';
@@ -0,0 +1,16 @@
1
+ /** Context passed to function items of a server `source.globalVars` chain. */
2
+ export interface ServerGlobalVarsContext {
3
+ env: string;
4
+ target: 'web' | 'node' | 'web-worker';
5
+ }
6
+ export type ServerGlobalVars = Record<string, unknown>;
7
+ type ServerGlobalVarsChainItem = ServerGlobalVars | ((config: ServerGlobalVars, context: ServerGlobalVarsContext) => ServerGlobalVars | void);
8
+ /** Source options that server code reads from the application config. */
9
+ export interface ServerSourceConfigExtension {
10
+ /**
11
+ * Compile-time constants replaced in server code, such as BFF handlers.
12
+ * Server code resolves the chain with `{ env: 'server', target: 'node' }`.
13
+ */
14
+ globalVars?: ServerGlobalVarsChainItem | ServerGlobalVarsChainItem[];
15
+ }
16
+ export {};
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@bleedingdev/modern-js-runtime-extensions",
3
3
  "description": "Fork-owned UltraModern.js runtime transaction, streaming, and safe failure primitives.",
4
- "homepage": "https://github.com/BleedingDev/ultramodern.js#readme",
4
+ "homepage": "https://github.com/ultramodernjs/ultramodern.js#readme",
5
5
  "bugs": {
6
- "url": "https://github.com/BleedingDev/ultramodern.js/issues"
6
+ "url": "https://github.com/ultramodernjs/ultramodern.js/issues"
7
7
  },
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/BleedingDev/ultramodern.js.git",
10
+ "url": "git+https://github.com/ultramodernjs/ultramodern.js.git",
11
11
  "directory": "packages/runtime/runtime-extensions"
12
12
  },
13
13
  "license": "MIT",
14
- "version": "3.9.0-ultramodern.9",
14
+ "version": "3.9.3-ultramodern.10",
15
15
  "engines": {
16
- "node": ">=26.7.0"
16
+ "node": ">=26.12.0"
17
17
  },
18
18
  "types": "./dist/types/index.d.ts",
19
19
  "main": "./dist/cjs/index.js",
@@ -42,14 +42,6 @@
42
42
  },
43
43
  "default": "./dist/esm/safeFailure.mjs"
44
44
  },
45
- "./build-identity": {
46
- "types": "./dist/types/buildIdentity.d.ts",
47
- "node": {
48
- "import": "./dist/esm-node/buildIdentity.mjs",
49
- "require": "./dist/cjs/buildIdentity.js"
50
- },
51
- "default": "./dist/esm/buildIdentity.mjs"
52
- },
53
45
  "./workspace-events": {
54
46
  "types": "./dist/types/workspaceEvents.d.ts",
55
47
  "node": {
@@ -70,14 +62,6 @@
70
62
  },
71
63
  "default": "./dist/esm/localisedLoaderIdentity.mjs"
72
64
  },
73
- "./react-context": {
74
- "types": "./dist/types/reactContext.d.ts",
75
- "node": {
76
- "import": "./dist/esm-node/reactContext.mjs",
77
- "require": "./dist/cjs/reactContext.js"
78
- },
79
- "default": "./dist/esm/reactContext.mjs"
80
- },
81
65
  "./context-extensions": {
82
66
  "types": "./dist/types/contextExtensions.d.ts",
83
67
  "node": {
@@ -186,6 +170,14 @@
186
170
  "require": "./dist/cjs/routerPrefetchPolicy.js"
187
171
  },
188
172
  "default": "./dist/esm/routerPrefetchPolicy.mjs"
173
+ },
174
+ "./localised-urls": {
175
+ "types": "./dist/types/localisedUrls/index.d.ts",
176
+ "node": {
177
+ "import": "./dist/esm-node/localisedUrls/index.mjs",
178
+ "require": "./dist/cjs/localisedUrls/index.js"
179
+ },
180
+ "default": "./dist/esm/localisedUrls/index.mjs"
189
181
  }
190
182
  },
191
183
  "typesVersions": {
@@ -202,10 +194,6 @@
202
194
  "./dist/types/safeFailure.d.ts",
203
195
  "./src/safeFailure.ts"
204
196
  ],
205
- "build-identity": [
206
- "./dist/types/buildIdentity.d.ts",
207
- "./src/buildIdentity.ts"
208
- ],
209
197
  "workspace-events": [
210
198
  "./dist/types/workspaceEvents.d.ts",
211
199
  "./src/workspaceEvents.ts"
@@ -214,10 +202,6 @@
214
202
  "./dist/types/localisedLoaderIdentity.d.ts",
215
203
  "./src/localisedLoaderIdentity.ts"
216
204
  ],
217
- "react-context": [
218
- "./dist/types/reactContext.d.ts",
219
- "./src/reactContext.ts"
220
- ],
221
205
  "context-extensions": [
222
206
  "./dist/types/contextExtensions.d.ts",
223
207
  "./src/contextExtensions.ts"
@@ -269,47 +253,61 @@
269
253
  "router-prefetch-policy": [
270
254
  "./dist/types/routerPrefetchPolicy.d.ts",
271
255
  "./src/routerPrefetchPolicy.ts"
256
+ ],
257
+ "localised-urls": [
258
+ "./dist/types/localisedUrls/index.d.ts",
259
+ "./src/localisedUrls/index.ts"
272
260
  ]
273
261
  }
274
262
  },
275
263
  "devDependencies": {
276
264
  "@loadable/component": "5.16.7",
277
- "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.9.0-ultramodern.9",
278
- "@rsbuild/core": "2.2.3",
279
- "@rslib/core": "1.0.0",
265
+ "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.9.3-ultramodern.10",
266
+ "@rsbuild/core": "2.2.13",
267
+ "@rslib/core": "1.0.3",
280
268
  "@scripts/rstest-config": "2.66.0",
281
- "@testing-library/dom": "^10.4.1",
269
+ "@testing-library/dom": "^10.4.2",
282
270
  "@testing-library/react": "^16.3.3",
283
- "@types/node": "^26.4.1",
284
- "@types/react": "^19.2.18",
285
- "@types/react-dom": "^19.2.7",
286
- "@typescript/native-preview": "7.0.0-dev.20260707.2",
271
+ "@types/node": "^26.6.5",
272
+ "@types/react": "^19.3.0",
273
+ "@types/react-dom": "^19.3.0",
287
274
  "esbuild": "^0.28.2",
288
- "nock": "^14.0.17",
289
- "react": "^19.2.8",
290
- "react-dom": "^19.2.8",
291
- "typescript": "^7.0.2"
275
+ "nock": "^15.0.1",
276
+ "react": "^19.3.1",
277
+ "react-dom": "^19.3.1",
278
+ "react-helmet-async": "3.0.0",
279
+ "typescript": "7.0.2"
292
280
  },
293
281
  "sideEffects": false,
294
282
  "publishConfig": {
295
283
  "access": "public"
296
284
  },
297
285
  "peerDependencies": {
298
- "react": "^19.2.8"
286
+ "react": "^19.3.1",
287
+ "react-helmet-async": "3.0.0"
299
288
  },
300
289
  "peerDependenciesMeta": {
301
290
  "react": {
302
291
  "optional": true
292
+ },
293
+ "react-helmet-async": {
294
+ "optional": true
303
295
  }
304
296
  },
305
297
  "dependencies": {
306
- "@modern-js/create-request": "npm:@bleedingdev/modern-js-create-request@3.9.0-ultramodern.9",
307
- "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.9",
308
- "react-helmet-async": "3.0.0"
298
+ "@modern-js/create-request": "npm:@bleedingdev/modern-js-create-request@3.9.3-ultramodern.10",
299
+ "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.9.3-ultramodern.10"
309
300
  },
301
+ "files": [
302
+ "dist",
303
+ "src"
304
+ ],
310
305
  "scripts": {
311
306
  "dev": "rslib build --watch",
312
307
  "build": "rslib build",
313
308
  "test": "rstest"
309
+ },
310
+ "ultramodern": {
311
+ "sourceName": "@modern-js/runtime-extensions"
314
312
  }
315
313
  }
@@ -149,7 +149,7 @@ const createElementFactory = <Element>(react: ElementFactory<Element>) =>
149
149
 
150
150
  export const renderHeadMarker = <Element>(
151
151
  react: ElementFactory<Element>,
152
- marker: MarkerProps | null | undefined,
152
+ marker: MarkerProps | undefined,
153
153
  ): Element | null =>
154
154
  marker ? createElementFactory(react)('template', marker) : null;
155
155
 
@@ -0,0 +1,18 @@
1
+ import type { LocalisedUrlsOption, ResolvedLocalisedUrlsConfig } from './types';
2
+
3
+ /**
4
+ * Localised URLs are strictly opt-in: only an explicit, non-empty map enables
5
+ * route expansion and validation. `true`, `false`, an empty map and absence
6
+ * all resolve to disabled, so upstream-style configs (`localePathRedirect` +
7
+ * `languages` without a map) keep plain locale-prefix behavior instead of
8
+ * failing the build for every route missing from a map they never wrote.
9
+ */
10
+ export const resolveLocalisedUrlsConfig = (
11
+ option: LocalisedUrlsOption | undefined,
12
+ ): ResolvedLocalisedUrlsConfig => {
13
+ if (option && typeof option === 'object' && Object.keys(option).length > 0) {
14
+ return { enabled: true, map: option };
15
+ }
16
+
17
+ return { enabled: false, map: {} };
18
+ };
@@ -0,0 +1,25 @@
1
+ export { resolveLocalisedUrlsConfig } from './config';
2
+ export { normalisePathname, normalisePathPattern } from './normalise';
3
+ export {
4
+ canonicalTargetPathname,
5
+ localiseTargetPathname,
6
+ stripLanguagePrefix,
7
+ } from './pathname';
8
+ export { buildPathFromPattern, matchPathPattern } from './patterns';
9
+ export type { LocaleRedirectSkipRule } from './redirect';
10
+ export {
11
+ DEFAULT_LOCALE_REDIRECT_SKIP_RULES,
12
+ isDefaultLocaleRedirectSkipPath,
13
+ matchesPathPrefix,
14
+ shouldSkipLocaleRedirect,
15
+ } from './redirect';
16
+ export { resolveCanonicalLocalisedPath, resolveLocalisedPath } from './resolve';
17
+ export { createLocalisedRouteRewrite } from './routeIdentity';
18
+ export { applyLocalisedUrlsToRoutes, validateLocalisedUrls } from './routes';
19
+ export type {
20
+ LocalisedRoute,
21
+ LocalisedUrlPathMap,
22
+ LocalisedUrlsMap,
23
+ LocalisedUrlsOption,
24
+ ResolvedLocalisedUrlsConfig,
25
+ } from './types';
@@ -0,0 +1,62 @@
1
+ const LOCALE_PARAM_NAMES = new Set(['lang', 'locale', 'language']);
2
+
3
+ const normaliseSlashes = (path: string): string => {
4
+ const withoutDuplicateSlashes = path.replace(/\/+/g, '/');
5
+ const withLeadingSlash = withoutDuplicateSlashes.startsWith('/')
6
+ ? withoutDuplicateSlashes
7
+ : `/${withoutDuplicateSlashes}`;
8
+
9
+ return withLeadingSlash.length > 1
10
+ ? withLeadingSlash.replace(/\/+$/, '')
11
+ : withLeadingSlash;
12
+ };
13
+
14
+ export const normalisePathPattern = (path: string): string =>
15
+ normaliseSlashes(path).replace(/\[(.+?)\]/g, ':$1');
16
+
17
+ /**
18
+ * Normalise a concrete request pathname: slash cleanup only. Unlike
19
+ * {@link normalisePathPattern} it must not rewrite literal `[x]` segments to
20
+ * `:x` params — pathnames are values, not patterns.
21
+ */
22
+ export const normalisePathname = (pathname: string): string =>
23
+ normaliseSlashes(pathname);
24
+
25
+ export const normaliseRoutePath = (path: string): string => {
26
+ const normalized = normalisePathPattern(path);
27
+ return normalized === '/' ? '' : normalized.slice(1);
28
+ };
29
+
30
+ const getLocaleParamSegment = (segment: string): string | null => {
31
+ if (!segment.startsWith(':')) {
32
+ return null;
33
+ }
34
+
35
+ const paramName = segment.slice(1).replace(/\?$/, '');
36
+ return LOCALE_PARAM_NAMES.has(paramName) ? segment : null;
37
+ };
38
+
39
+ const splitPathSegments = (path?: string): string[] => {
40
+ if (!path) {
41
+ return [];
42
+ }
43
+
44
+ return normalisePathPattern(path).split('/').filter(Boolean);
45
+ };
46
+
47
+ export const stripLeadingLocaleParam = (path?: string): string | undefined => {
48
+ const segments = splitPathSegments(path);
49
+ const leadingLocaleParam = getLocaleParamSegment(segments[0] || '');
50
+
51
+ if (!leadingLocaleParam) {
52
+ return path;
53
+ }
54
+
55
+ const remainingPath = segments.slice(1).join('/');
56
+ return remainingPath ? `/${remainingPath}` : undefined;
57
+ };
58
+
59
+ export const getLeadingLocaleParam = (path?: string): string | null => {
60
+ const segments = splitPathSegments(path);
61
+ return getLocaleParamSegment(segments[0] || '');
62
+ };
@@ -0,0 +1,60 @@
1
+ import { resolveLocalisedUrlsConfig } from './config';
2
+
3
+ import { resolveCanonicalLocalisedPath, resolveLocalisedPath } from './resolve';
4
+
5
+ import type { LocalisedUrlsOption } from './types';
6
+
7
+ export const stripLanguagePrefix = (
8
+ pathname: string,
9
+ languages: string[],
10
+ ): string => {
11
+ const segments = pathname.split('/').filter(Boolean);
12
+ const firstSegment = segments[0]?.toLowerCase();
13
+
14
+ if (
15
+ firstSegment &&
16
+ languages.some(language => language.toLowerCase() === firstSegment)
17
+ ) {
18
+ return `/${segments.slice(1).join('/')}`;
19
+ }
20
+
21
+ return pathname || '/';
22
+ };
23
+
24
+ export const localiseTargetPathname = (
25
+ pathname: string,
26
+ language: string,
27
+ languages: string[],
28
+ localisedUrls?: LocalisedUrlsOption,
29
+ ): string => {
30
+ const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
31
+ const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
32
+ const resolvedPath = localisedUrlsConfig.enabled
33
+ ? resolveLocalisedPath(
34
+ pathWithoutLanguage,
35
+ language,
36
+ languages,
37
+ localisedUrlsConfig.map,
38
+ )
39
+ : pathWithoutLanguage;
40
+ const resolvedSegments = resolvedPath.split('/').filter(Boolean);
41
+
42
+ return `/${[language, ...resolvedSegments].join('/')}`;
43
+ };
44
+
45
+ export const canonicalTargetPathname = (
46
+ pathname: string,
47
+ languages: string[],
48
+ localisedUrls?: LocalisedUrlsOption,
49
+ ): string => {
50
+ const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
51
+ const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
52
+
53
+ return localisedUrlsConfig.enabled
54
+ ? resolveCanonicalLocalisedPath(
55
+ pathWithoutLanguage,
56
+ languages,
57
+ localisedUrlsConfig.map,
58
+ )
59
+ : pathWithoutLanguage;
60
+ };